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
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

View File

@ -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
fi

View File

@ -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
docker rm local-api

View File

@ -53,6 +53,7 @@ struct AssetPermissionInfo {
role: AssetPermissionRole,
email: String,
name: Option<String>,
avatar_url: Option<String>,
}
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::<AssetPermissionInfo>(&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::<Vec<BusterShareIndividual>>(),
)

View File

@ -78,7 +78,7 @@ pub async fn list_chats_handler(
request: ListChatsRequest,
user: &AuthenticatedUser,
) -> 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?;
@ -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::<diesel::sql_types::Bool>(
"(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)
}
}

View File

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

View File

@ -25,6 +25,7 @@ struct AssetPermissionInfo {
role: AssetPermissionRole,
email: String,
name: Option<String>,
avatar_url: Option<String>,
}
/// 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::<AssetPermissionInfo>(&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::<Vec<BusterShareIndividual>>(),
)

View File

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

View File

@ -52,6 +52,7 @@ struct AssetPermissionInfo {
role: AssetPermissionRole,
email: String,
name: Option<String>,
avatar_url: Option<String>,
}
/// 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::<AssetPermissionInfo>(&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::<Vec<BusterShareIndividual>>(),
)

View File

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

View File

@ -69,7 +69,7 @@ pub async fn list_logs_handler(
request: ListLogsRequest,
organization_id: Uuid,
) -> Result<ListLogsResponse, anyhow::Error> {
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::<diesel::sql_types::Bool>(
"(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

View File

@ -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

View File

@ -32,6 +32,7 @@ struct AssetPermissionInfo {
role: AssetPermissionRole,
email: String,
name: Option<String>,
avatar_url: Option<String>,
}
/// 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::<AssetPermissionInfo>(&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::<Vec<BusterShareIndividual>>(),
)

View File

@ -30,6 +30,7 @@ struct AssetPermissionInfo {
role: AssetPermissionRole,
email: String,
name: Option<String>,
avatar_url: Option<String>,
}
/// 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::<AssetPermissionInfo>(&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::<Vec<crate::metrics::types::BusterShareIndividual>>(),
)

View File

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

View File

@ -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<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)]
mod tests {
use super::*;

View File

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

View File

@ -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 = {

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 {
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) */

View File

@ -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

View File

@ -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 */

View File

@ -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<GetMetricResponse> => {
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<string>(`/metric_files/${id}/sharing`, params).then((res) => res.data);
};

View File

@ -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

View File

@ -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<typeof AssetPermissionRoleSchema>;
export type BusterShareIndividual = z.infer<typeof BusterShareIndividualSchema>;
export type ChatWithMessages = z.infer<typeof ChatWithMessagesSchema>;
export type ChatCreateRequest = z.infer<typeof ChatCreateRequestSchema>;
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 ShareUpdateRequestSchema = z.object({
@ -87,6 +78,5 @@ export type DuplicateMetricRequest = z.infer<typeof DuplicateMetricRequestSchema
export type BulkUpdateMetricVerificationStatusRequest = z.infer<
typeof BulkUpdateMetricVerificationStatusRequestSchema
>;
export type ShareMetricRequest = z.infer<typeof ShareMetricRequestSchema>;
export type ShareDeleteRequest = z.infer<typeof ShareDeleteRequestSchema>;
export type ShareUpdateRequest = z.infer<typeof ShareUpdateRequestSchema>;

View File

@ -1,2 +1,3 @@
export * from './share-interfaces.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 { 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'
);

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 };
}