mirror of https://github.com/buster-so/buster.git
Merge branch 'staging' into big-nate/bus-1424-default-color-palette-in-workspace-settings
This commit is contained in:
commit
be6b510685
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>>(),
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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>>(),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>>(),
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>>(),
|
||||
)
|
||||
|
|
|
@ -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>>(),
|
||||
)
|
||||
|
|
|
@ -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)]
|
||||
|
|
|
@ -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::*;
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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) */
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -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>;
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
export * from './share-interfaces.types';
|
||||
export * from './verification.types';
|
||||
export * from './requests';
|
||||
|
|
|
@ -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>;
|
|
@ -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'
|
||||
);
|
||||
|
||||
|
|
|
@ -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 ';
|
||||
const result = convertMarkdownToSlack(markdown);
|
||||
|
||||
expect(result.text).toBe('Text with [link](http://example.com) and ');
|
||||
});
|
||||
});
|
|
@ -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 };
|
||||
}
|
Loading…
Reference in New Issue