feat: add report files support to collections functionality

- Create report_files.rs helper module with permission checking
- Add ReportFile asset type handling in add_assets_to_collection_handler
- Add Report variant to AssetToRemove enum for removal operations
- Include report files in collection queries and responses
- Follow existing patterns for MetricFile and DashboardFile
This commit is contained in:
dal 2025-08-22 09:59:24 -06:00
parent ad4d7a3134
commit 41ca18ea98
No known key found for this signature in database
GPG Key ID: 16F4B0E1E9F61122
5 changed files with 429 additions and 4 deletions

View File

@ -1,6 +1,7 @@
pub mod collections;
pub mod dashboard_files;
pub mod metric_files;
pub mod report_files;
pub mod chats;
pub mod organization;
pub mod test_utils;

View File

@ -0,0 +1,155 @@
use crate::enums::{AssetPermissionRole, AssetType};
use anyhow::Result;
use diesel::JoinOnDsl;
use diesel::{BoolExpressionMethods, ExpressionMethods, QueryDsl, Queryable};
use diesel_async::RunQueryDsl;
use tokio::try_join;
use uuid::Uuid;
use crate::models::{AssetPermission, ReportFile};
use crate::pool::get_pg_pool;
use crate::schema::{asset_permissions, collections_to_assets, report_files};
/// Fetches a single report file by ID that hasn't been deleted
///
/// # Arguments
/// * `id` - The UUID of the report file to fetch
///
/// # Returns
/// * `Result<Option<ReportFile>>` - The report file if found and not deleted
pub async fn fetch_report_file(id: &Uuid) -> Result<Option<ReportFile>> {
let mut conn = get_pg_pool().get().await?;
let result = match report_files::table
.filter(report_files::id.eq(id))
.filter(report_files::deleted_at.is_null())
.first::<ReportFile>(&mut conn)
.await
{
Ok(result) => Some(result),
Err(diesel::NotFound) => None,
Err(e) => return Err(e.into()),
};
Ok(result)
}
/// Helper function to fetch a report file by ID
async fn fetch_report(id: &Uuid) -> Result<Option<ReportFile>> {
let mut conn = get_pg_pool().get().await?;
let result = match report_files::table
.filter(report_files::id.eq(id))
.filter(report_files::deleted_at.is_null())
.first::<ReportFile>(&mut conn)
.await
{
Ok(result) => Some(result),
Err(diesel::NotFound) => None,
Err(e) => return Err(e.into()),
};
Ok(result)
}
/// Helper function to check if a report file is publicly accessible
async fn is_publicly_accessible(report_file: &ReportFile) -> bool {
// Check if the file is publicly accessible and either has no expiry date
// or the expiry date has not passed
report_file.publicly_accessible
&& report_file
.public_expiry_date
.map_or(true, |expiry| expiry > chrono::Utc::now())
}
/// Helper function to fetch permission for a report file
async fn fetch_permission(id: &Uuid, user_id: &Uuid) -> Result<Option<AssetPermissionRole>> {
let mut conn = get_pg_pool().get().await?;
let permission = match asset_permissions::table
.filter(asset_permissions::asset_id.eq(id))
.filter(asset_permissions::asset_type.eq(AssetType::ReportFile))
.filter(asset_permissions::identity_id.eq(user_id))
.filter(asset_permissions::deleted_at.is_null())
.first::<AssetPermission>(&mut conn)
.await
{
Ok(result) => Some(result.role),
Err(diesel::NotFound) => None,
Err(e) => return Err(e.into()),
};
Ok(permission)
}
/// Helper function to fetch collection-based permissions for a report file
///
/// Checks if the user has access to the report file through collections
/// by finding collections that contain this report file and checking
/// if the user has permissions on those collections
async fn fetch_collection_permissions_for_report(
id: &Uuid,
user_id: &Uuid,
) -> Result<Option<AssetPermissionRole>> {
let mut conn = get_pg_pool().get().await?;
// Find collections containing this report file
// then join with asset_permissions to find user's permissions on those collections
let permissions = asset_permissions::table
.inner_join(
collections_to_assets::table.on(asset_permissions::asset_id
.eq(collections_to_assets::collection_id)
.and(asset_permissions::asset_type.eq(AssetType::Collection))
.and(collections_to_assets::asset_id.eq(id))
.and(collections_to_assets::asset_type.eq(AssetType::ReportFile))
.and(collections_to_assets::deleted_at.is_null())),
)
.filter(asset_permissions::identity_id.eq(user_id))
.filter(asset_permissions::deleted_at.is_null())
.select(asset_permissions::role)
.load::<AssetPermissionRole>(&mut conn)
.await?;
// Return any collection-based permission (no need to determine highest)
if permissions.is_empty() {
Ok(None)
} else {
// Just take the first one since any collection permission is sufficient
Ok(permissions.into_iter().next())
}
}
#[derive(Queryable)]
pub struct ReportFileWithPermission {
pub report_file: ReportFile,
pub permission: Option<AssetPermissionRole>,
}
pub async fn fetch_report_file_with_permission(
id: &Uuid,
user_id: &Uuid,
) -> Result<Option<ReportFileWithPermission>> {
// Run all queries concurrently
let (report_file, direct_permission, collection_permission) = try_join!(
fetch_report(id),
fetch_permission(id, user_id),
fetch_collection_permissions_for_report(id, user_id)
)?;
// If the report file doesn't exist, return None
let report_file = match report_file {
Some(file) => file,
None => return Ok(None),
};
// Determine effective permission (prioritizing collection over direct)
let effective_permission = match collection_permission {
Some(collection) => Some(collection),
None => direct_permission,
};
Ok(Some(ReportFileWithPermission {
report_file,
permission: effective_permission,
}))
}

View File

@ -4,6 +4,7 @@ use database::{
enums::{AssetPermissionRole, AssetType},
helpers::collections::fetch_collection_with_permission,
metric_files::fetch_metric_file_with_permissions,
report_files::fetch_report_file_with_permission,
chats::fetch_chat_with_permission,
models::CollectionToAsset,
pool::get_pg_pool,
@ -116,12 +117,14 @@ pub async fn add_assets_to_collection_handler(
let mut dashboard_ids = Vec::new();
let mut metric_ids = Vec::new();
let mut chat_ids = Vec::new();
let mut report_ids = Vec::new();
for asset in &assets {
match asset.asset_type {
AssetType::DashboardFile => dashboard_ids.push(asset.id),
AssetType::MetricFile => metric_ids.push(asset.id),
AssetType::Chat => chat_ids.push(asset.id),
AssetType::ReportFile => report_ids.push(asset.id),
_ => {
error!(
asset_id = %asset.id,
@ -567,6 +570,161 @@ pub async fn add_assets_to_collection_handler(
}
}
// Process reports
if !report_ids.is_empty() {
for report_id in &report_ids {
// Check if report exists
let report = fetch_report_file_with_permission(&report_id, &user.id).await?;
let report = if let Some(report) = report {
report
} else {
error!(
report_id = %report_id,
user_id = %user.id,
"Report not found"
);
result.failed_count += 1;
result.failed_assets.push((
*report_id,
AssetType::ReportFile,
"Report not found".to_string(),
));
continue;
};
// Check if user has access to the report
let has_report_permission = check_permission_access(
report.permission,
&[
AssetPermissionRole::CanView,
AssetPermissionRole::CanEdit,
AssetPermissionRole::FullAccess,
AssetPermissionRole::Owner,
],
report.report_file.organization_id,
&user.organizations,
report.report_file.workspace_sharing,
);
if !has_report_permission {
error!(
report_id = %report_id,
user_id = %user.id,
"User does not have permission to access this report"
);
result.failed_count += 1;
result.failed_assets.push((
*report_id,
AssetType::ReportFile,
"Insufficient permissions".to_string(),
));
continue;
}
// Check if the report is already in the collection
let existing = match collections_to_assets::table
.filter(collections_to_assets::collection_id.eq(collection_id))
.filter(collections_to_assets::asset_id.eq(report_id))
.filter(collections_to_assets::asset_type.eq(AssetType::ReportFile))
.first::<CollectionToAsset>(&mut conn)
.await
{
Ok(record) => Some(record),
Err(diesel::NotFound) => None,
Err(e) => {
error!("Error checking if report is already in collection: {}", e);
result.failed_count += 1;
result.failed_assets.push((
*report_id,
AssetType::ReportFile,
format!("Database error: {}", e),
));
continue;
}
};
if let Some(existing_record) = existing {
if existing_record.deleted_at.is_some() {
// If it was previously deleted, update it
match diesel::update(collections_to_assets::table)
.filter(collections_to_assets::collection_id.eq(collection_id))
.filter(collections_to_assets::asset_id.eq(report_id))
.filter(collections_to_assets::asset_type.eq(AssetType::ReportFile))
.set((
collections_to_assets::deleted_at
.eq::<Option<chrono::DateTime<chrono::Utc>>>(None),
collections_to_assets::updated_at.eq(chrono::Utc::now()),
collections_to_assets::updated_by.eq(user.id),
))
.execute(&mut conn)
.await
{
Ok(_) => {
result.added_count += 1;
}
Err(e) => {
error!(
collection_id = %collection_id,
report_id = %report_id,
"Error updating report in collection: {}", e
);
result.failed_count += 1;
result.failed_assets.push((
*report_id,
AssetType::ReportFile,
format!("Database error: {}", e),
));
}
}
} else {
// Already in the collection
info!(
collection_id = %collection_id,
report_id = %report_id,
"Report already in collection"
);
result.added_count += 1;
}
} else {
// Add to collection
let new_record = CollectionToAsset {
collection_id: *collection_id,
asset_id: *report_id,
asset_type: AssetType::ReportFile,
created_at: chrono::Utc::now(),
created_by: user.id,
updated_at: chrono::Utc::now(),
updated_by: user.id,
deleted_at: None,
};
match diesel::insert_into(collections_to_assets::table)
.values(&new_record)
.execute(&mut conn)
.await
{
Ok(_) => {
result.added_count += 1;
}
Err(e) => {
error!(
collection_id = %collection_id,
report_id = %report_id,
"Error adding report to collection: {}", e
);
result.failed_count += 1;
result.failed_assets.push((
*report_id,
AssetType::ReportFile,
format!("Database error: {}", e),
));
}
}
}
}
}
Ok(result)
}

View File

@ -6,7 +6,7 @@ use database::{
pool::get_pg_pool,
schema::{
asset_permissions, collections_to_assets, dashboard_files, metric_files, users,
chats,
chats, report_files,
},
};
use diesel::{ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, Queryable};
@ -272,11 +272,39 @@ pub async fn get_collection_handler(
}
});
let report_assets_handle = tokio::spawn({
let pool = pool.clone();
let req_id = req.id;
async move {
let mut conn = pool.get().await.map_err(anyhow::Error::from)?;
collections_to_assets::table
.inner_join(report_files::table.on(report_files::id.eq(collections_to_assets::asset_id)))
.left_join(users::table.on(users::id.eq(report_files::created_by)))
.filter(collections_to_assets::collection_id.eq(req_id))
.filter(collections_to_assets::asset_type.eq(AssetType::ReportFile))
.filter(collections_to_assets::deleted_at.is_null())
.filter(report_files::deleted_at.is_null())
.select((
report_files::id,
report_files::name,
users::name.nullable(),
users::email.nullable(),
report_files::created_at,
report_files::updated_at,
collections_to_assets::asset_type,
))
.load::<AssetQueryResult>(&mut conn)
.await
.map_err(anyhow::Error::from)
}
});
// Await all tasks and handle results
let (metric_assets_result, dashboard_assets_result, chat_assets_result) = tokio::join!(
let (metric_assets_result, dashboard_assets_result, chat_assets_result, report_assets_result) = tokio::join!(
metric_assets_handle,
dashboard_assets_handle,
chat_assets_handle
chat_assets_handle,
report_assets_handle
);
// Process metric assets
@ -318,10 +346,23 @@ pub async fn get_collection_handler(
}
};
// Process report assets
let report_assets = match report_assets_result {
Ok(Ok(assets)) => assets,
Ok(Err(e)) => {
tracing::error!("Failed to fetch report assets: {}", e);
vec![]
}
Err(e) => {
tracing::error!("Report asset task failed: {}", e);
vec![]
}
};
// println!("dashboard_assets: {:?}", dashboard_assets); // Keep or remove debug print?
// Combine all assets
let all_assets = [metric_assets, dashboard_assets, chat_assets].concat(); // Add chat_assets
let all_assets = [metric_assets, dashboard_assets, chat_assets, report_assets].concat();
let formatted_assets = format_assets(all_assets);
// Get workspace sharing enabled by email if set

View File

@ -20,6 +20,8 @@ pub enum AssetToRemove {
Dashboard(Uuid),
/// Metric ID to remove
Metric(Uuid),
/// Report ID to remove
Report(Uuid),
}
/// Result of removing assets from a collection
@ -246,6 +248,74 @@ pub async fn remove_assets_from_collection_handler(
));
}
}
AssetToRemove::Report(report_id) => {
// Check if the report is in the collection
let existing = match collections_to_assets::table
.filter(collections_to_assets::collection_id.eq(collection_id))
.filter(collections_to_assets::asset_id.eq(report_id))
.filter(collections_to_assets::asset_type.eq(AssetType::ReportFile))
.filter(collections_to_assets::deleted_at.is_null())
.first::<CollectionToAsset>(&mut conn)
.await
{
Ok(record) => Some(record),
Err(diesel::NotFound) => None,
Err(e) => {
error!(
"Error checking if report is in collection: {}",
e
);
result.failed_count += 1;
result.failed_assets.push((
report_id,
AssetType::ReportFile,
format!("Database error: {}", e),
));
continue;
}
};
if let Some(existing_record) = existing {
// Soft delete the record
match diesel::update(collections_to_assets::table)
.filter(collections_to_assets::collection_id.eq(existing_record.collection_id))
.filter(collections_to_assets::asset_id.eq(existing_record.asset_id))
.filter(collections_to_assets::asset_type.eq(existing_record.asset_type))
.set((
collections_to_assets::deleted_at.eq(chrono::Utc::now()),
collections_to_assets::updated_at.eq(chrono::Utc::now()),
collections_to_assets::updated_by.eq(user.id),
))
.execute(&mut conn)
.await
{
Ok(_) => {
result.removed_count += 1;
}
Err(e) => {
error!(
collection_id = %collection_id,
report_id = %report_id,
"Error removing report from collection: {}", e
);
result.failed_count += 1;
result.failed_assets.push((
report_id,
AssetType::ReportFile,
format!("Database error: {}", e),
));
}
}
} else {
// Report is not in the collection, count as failed
result.failed_count += 1;
result.failed_assets.push((
report_id,
AssetType::ReportFile,
"Report is not in the collection".to_string(),
));
}
}
}
}