diff --git a/api/libs/handlers/src/chats/mod.rs b/api/libs/handlers/src/chats/mod.rs index ab2f70ca2..6fbf5543c 100644 --- a/api/libs/handlers/src/chats/mod.rs +++ b/api/libs/handlers/src/chats/mod.rs @@ -17,5 +17,6 @@ pub use delete_chats_handler::delete_chats_handler; pub use list_chats_handler::list_chats_handler; pub use sharing::delete_chat_sharing_handler; pub use sharing::create_chat_sharing_handler; +pub use sharing::update_chat_sharing_handler; pub use types::*; pub use streaming_parser::StreamingParser; \ No newline at end of file diff --git a/api/libs/handlers/src/chats/sharing/mod.rs b/api/libs/handlers/src/chats/sharing/mod.rs index 2d80f947a..74148049d 100644 --- a/api/libs/handlers/src/chats/sharing/mod.rs +++ b/api/libs/handlers/src/chats/sharing/mod.rs @@ -1,7 +1,9 @@ mod list_sharing_handler; mod delete_sharing_handler; mod create_sharing_handler; +mod update_sharing_handler; pub use list_sharing_handler::list_chat_sharing_handler; pub use delete_sharing_handler::delete_chat_sharing_handler; pub use create_sharing_handler::create_chat_sharing_handler; +pub use update_sharing_handler::update_chat_sharing_handler; diff --git a/api/libs/handlers/src/chats/sharing/update_sharing_handler.rs b/api/libs/handlers/src/chats/sharing/update_sharing_handler.rs new file mode 100644 index 000000000..b6d2f9a8c --- /dev/null +++ b/api/libs/handlers/src/chats/sharing/update_sharing_handler.rs @@ -0,0 +1,97 @@ +use anyhow::{anyhow, Result}; +use database::{ + enums::{AssetPermissionRole, AssetType, IdentityType}, +}; +use sharing::{create_share_by_email, has_permission}; +use tracing::info; +use uuid::Uuid; + +use super::create_sharing_handler::get_chat_exists; + +/// Updates sharing permissions for a chat +/// +/// # Arguments +/// * `chat_id` - The ID of the chat to update sharing for +/// * `user_id` - The ID of the user updating the sharing permissions +/// * `emails_and_roles` - List of (email, role) pairs representing the recipients and their access levels +/// +/// # Returns +/// * `Result<()>` - Success or error +pub async fn update_chat_sharing_handler( + chat_id: &Uuid, + user_id: &Uuid, + emails_and_roles: Vec<(String, AssetPermissionRole)>, +) -> Result<()> { + info!( + chat_id = %chat_id, + user_id = %user_id, + "Updating sharing permissions for chat" + ); + + // 1. Validate the chat exists + let chat_exists = get_chat_exists(chat_id).await?; + + if !chat_exists { + return Err(anyhow!("Chat not found")); + } + + // 2. Check if user has permission to update sharing for the chat (Owner or FullAccess) + let has_perm = has_permission( + *chat_id, + AssetType::Chat, + *user_id, + IdentityType::User, + AssetPermissionRole::FullAccess, // Owner role implicitly has FullAccess permissions + ).await?; + + if !has_perm { + return Err(anyhow!("User does not have permission to update sharing for this chat")); + } + + // 3. Process each email and update sharing permissions + for (email, role) in emails_and_roles { + // The create_share_by_email function handles both creation and updates + // It performs an upsert operation in the database + match create_share_by_email( + &email, + *chat_id, + AssetType::Chat, + role, + *user_id, + ).await { + Ok(_) => { + info!("Updated sharing permission for email: {} on chat: {} with role: {:?}", email, chat_id, role); + }, + Err(e) => { + tracing::error!("Failed to update sharing for email {}: {}", email, e); + return Err(anyhow!("Failed to update sharing for email {}: {}", email, e)); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + #[tokio::test] + async fn test_update_chat_sharing_handler_chat_not_found() { + // Mock UUID for chat and user + let chat_id = Uuid::new_v4(); + let user_id = Uuid::new_v4(); + + // Test with empty request to simplify the test + let emails_and_roles: Vec<(String, AssetPermissionRole)> = vec![]; + + // Call handler - should fail because the chat doesn't exist + let result = update_chat_sharing_handler(&chat_id, &user_id, emails_and_roles).await; + + // Since we can't easily mock the function in an integration test + // This is just a placeholder for the real test + // The actual error could be different based on the testing environment + assert!(result.is_err()); + } +} \ No newline at end of file diff --git a/api/prds/active/api_chats_sharing_summary.md b/api/prds/active/api_chats_sharing_summary.md index fc413a70b..c38f77b83 100644 --- a/api/prds/active/api_chats_sharing_summary.md +++ b/api/prds/active/api_chats_sharing_summary.md @@ -26,17 +26,17 @@ The implementation is broken down into the following components, each with its o ### PRD Development Order The PRDs can be developed in the following order, with opportunities for parallel work: -1. **First: List Chats Sharing Endpoint PRD** (api_chats_sharing_list.md) +1. **First: List Chats Sharing Endpoint PRD** (api_chats_sharing_list.md) ✅ - This PRD should be completed first as it establishes the basic data structures and permission checking patterns that other PRDs will build upon. - It introduces the core response types and error handling approaches. 2. **Second (Can be done in parallel):** - - **Create Chats Sharing Endpoint PRD** (api_chats_sharing_create.md) - - **Delete Chats Sharing Endpoint PRD** (api_chats_sharing_delete.md) + - **Create Chats Sharing Endpoint PRD** (api_chats_sharing_create.md) ✅ + - **Delete Chats Sharing Endpoint PRD** (api_chats_sharing_delete.md) ✅ - These PRDs can be worked on simultaneously by different team members after the List PRD is complete. - They use different sharing library functions and have minimal dependencies on each other. -3. **Third: Update Chats Sharing Endpoint PRD** (api_chats_sharing_update.md) +3. **Third: Update Chats Sharing Endpoint PRD** (api_chats_sharing_update.md) ✅ - This PRD should be completed after the Create PRD since it builds on similar concepts and uses the same underlying sharing library functions. - The update endpoint reuses many patterns from the create endpoint with slight modifications. @@ -61,12 +61,12 @@ After Phase 1 is complete, the following components can be implemented in parall - Uses `list_shares` from `@[api/libs/sharing/src]/list_asset_permissions.rs` - Uses `check_access` from `@[api/libs/sharing/src]/check_asset_permission.rs` -- **Create Sharing Endpoint** +- **Create Sharing Endpoint** ✅ - Uses `find_user_by_email` from `@[api/libs/sharing/src]/user_lookup.rs` - Uses `create_share_by_email` from `@[api/libs/sharing/src]/create_asset_permission.rs` - Uses `has_permission` from `@[api/libs/sharing/src]/check_asset_permission.rs` -- **Update Sharing Endpoint** +- **Update Sharing Endpoint** ✅ - Uses `create_share_by_email` from `@[api/libs/sharing/src]/create_asset_permission.rs` - Uses `has_permission` from `@[api/libs/sharing/src]/check_asset_permission.rs` diff --git a/api/prds/active/api_chats_sharing_update.md b/api/prds/active/api_chats_sharing_update.md index 4e6861c5e..a034d2d53 100644 --- a/api/prds/active/api_chats_sharing_update.md +++ b/api/prds/active/api_chats_sharing_update.md @@ -1,10 +1,10 @@ -# API Chats Sharing - Update Endpoint PRD +# API Chats Sharing - Update Endpoint PRD ✅ ## Problem Statement Users need the ability to update sharing permissions for chats through a REST API endpoint. -## Technical Design +## Technical Design ✅ ### Endpoint Specification diff --git a/api/src/routes/rest/routes/chats/sharing/mod.rs b/api/src/routes/rest/routes/chats/sharing/mod.rs index 945d43f53..f49e301e6 100644 --- a/api/src/routes/rest/routes/chats/sharing/mod.rs +++ b/api/src/routes/rest/routes/chats/sharing/mod.rs @@ -1,18 +1,21 @@ use axum::{ - routing::{get, post, delete}, + routing::{get, post, delete, put}, Router, }; mod list_sharing; mod delete_sharing; mod create_sharing; +mod update_sharing; pub use list_sharing::list_chat_sharing_rest_handler; pub use delete_sharing::delete_chat_sharing_rest_handler; +pub use update_sharing::update_chat_sharing_rest_handler; pub fn router() -> Router { Router::new() .route("/", get(list_chat_sharing_rest_handler)) .route("/", delete(delete_chat_sharing_rest_handler)) .route("/", post(create_sharing::create_chat_sharing_rest_handler)) + .route("/", put(update_chat_sharing_rest_handler)) } diff --git a/api/src/routes/rest/routes/chats/sharing/update_sharing.rs b/api/src/routes/rest/routes/chats/sharing/update_sharing.rs new file mode 100644 index 000000000..e7455f2a2 --- /dev/null +++ b/api/src/routes/rest/routes/chats/sharing/update_sharing.rs @@ -0,0 +1,55 @@ +use axum::{ + extract::Path, + http::StatusCode, + Extension, Json, +}; +use database::enums::AssetPermissionRole; +use middleware::AuthenticatedUser; +use serde::Deserialize; +use uuid::Uuid; + +/// Recipient for sharing a chat +#[derive(Debug, Deserialize)] +pub struct ShareRecipient { + pub email: String, + pub role: AssetPermissionRole, +} + +/// Update sharing permissions for a chat +/// +/// This endpoint updates sharing permissions for a chat with the provided details. +/// Requires Owner or FullAccess permission. +pub async fn update_chat_sharing_rest_handler( + Extension(user): Extension, + Path(id): Path, + Json(request): Json>, +) -> Result, (StatusCode, String)> { + tracing::info!("Processing PUT request for chat sharing with ID: {}, user_id: {}", id, user.id); + + // Convert request to a list of (email, role) pairs + let emails_and_roles: Vec<(String, AssetPermissionRole)> = request + .into_iter() + .map(|recipient| (recipient.email, recipient.role)) + .collect(); + + // Call the handler from the handlers crate + match handlers::chats::sharing::update_chat_sharing_handler(&id, &user.id, emails_and_roles).await { + Ok(_) => Ok(Json("Sharing permissions updated successfully".to_string())), + Err(e) => { + tracing::error!("Error updating sharing permissions: {}", e); + + // Map specific errors to appropriate status codes + let error_message = e.to_string(); + + if error_message.contains("not found") { + return Err((StatusCode::NOT_FOUND, format!("Chat not found: {}", e))); + } else if error_message.contains("permission") { + return Err((StatusCode::FORBIDDEN, format!("Insufficient permissions: {}", e))); + } else if error_message.contains("Invalid email") { + return Err((StatusCode::BAD_REQUEST, format!("Invalid email: {}", e))); + } + + Err((StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to update sharing permissions: {}", e))) + } + } +} \ No newline at end of file diff --git a/api/tests/integration/chats/sharing/mod.rs b/api/tests/integration/chats/sharing/mod.rs index 876b0a9a3..084f53d5b 100644 --- a/api/tests/integration/chats/sharing/mod.rs +++ b/api/tests/integration/chats/sharing/mod.rs @@ -1,3 +1,4 @@ mod list_sharing_test; pub mod delete_sharing_test; pub mod create_sharing_test; +pub mod update_sharing_test; diff --git a/api/tests/integration/chats/sharing/update_sharing_test.rs b/api/tests/integration/chats/sharing/update_sharing_test.rs new file mode 100644 index 000000000..1725bca44 --- /dev/null +++ b/api/tests/integration/chats/sharing/update_sharing_test.rs @@ -0,0 +1,224 @@ +use anyhow::Result; +use axum::{ + extract::Extension, + routing::put, + Router, +}; +use database::enums::{AssetPermissionRole, AssetType, IdentityType}; +use middleware::auth::AuthenticatedUser; +use serde_json::{json, Value}; +use sharing::create_share; +use src::routes::rest::routes::chats::sharing::update_chat_sharing_rest_handler; +use tests::common::{ + assertions::response::ResponseAssertions, + fixtures::builder::FixtureBuilder, + http::client::TestClient, +}; +use uuid::Uuid; + +// Test for PUT /chats/:id/sharing +// Creates a test server, adds test data, and makes a request to update chat sharing +#[tokio::test] +async fn test_update_chat_sharing_success() -> Result<()> { + // Set up test fixtures + let mut fixture = FixtureBuilder::new().await?; + + // Create a test user (this will be the owner of the chat) + let user = fixture.create_user().await?; + + // Create a test chat owned by the user + let chat = fixture.create_chat(&user.id).await?; + + // Create another user to share with + let share_recipient = fixture.create_user().await?; + + // Create a manual permission so our test user has Owner access to the chat + create_share( + chat.id, + AssetType::Chat, + user.id, + IdentityType::User, + AssetPermissionRole::Owner, + user.id, + ) + .await?; + + // Set up the test server with our endpoint + let app = Router::new() + .route("/chats/:id/sharing", put(update_chat_sharing_rest_handler)) + .layer(Extension(AuthenticatedUser { + id: user.id, + email: user.email.clone(), + org_id: None, + })); + + let client = TestClient::new(app); + + // Make the request to update sharing permissions + let payload = json!([ + { + "email": share_recipient.email, + "role": "Editor" // Update to Editor role + } + ]); + + let response = client + .put(&format!("/chats/{}/sharing", chat.id)) + .json(&payload) + .send() + .await?; + + // Assert the response is successful + response.assert_status_ok()?; + + Ok(()) +} + +#[tokio::test] +async fn test_update_chat_sharing_unauthorized() -> Result<()> { + // Set up test fixtures + let mut fixture = FixtureBuilder::new().await?; + + // Create two test users + let owner = fixture.create_user().await?; + let non_owner = fixture.create_user().await?; + + // Create a test chat owned by the first user + let chat = fixture.create_chat(&owner.id).await?; + + // Create a manual permission so the owner has Owner access + create_share( + chat.id, + AssetType::Chat, + owner.id, + IdentityType::User, + AssetPermissionRole::Owner, + owner.id, + ) + .await?; + + // Set up the test server with our endpoint but authenticated as non-owner + let app = Router::new() + .route("/chats/:id/sharing", put(update_chat_sharing_rest_handler)) + .layer(Extension(AuthenticatedUser { + id: non_owner.id, // This user doesn't have permission to update the chat sharing + email: non_owner.email.clone(), + org_id: None, + })); + + let client = TestClient::new(app); + + // Make the request + let payload = json!([ + { + "email": "test@example.com", + "role": "Editor" + } + ]); + + let response = client + .put(&format!("/chats/{}/sharing", chat.id)) + .json(&payload) + .send() + .await?; + + // Assert the response is forbidden + response.assert_status_forbidden()?; + + Ok(()) +} + +#[tokio::test] +async fn test_update_chat_sharing_not_found() -> Result<()> { + // Set up test fixtures + let mut fixture = FixtureBuilder::new().await?; + + // Create a test user + let user = fixture.create_user().await?; + + // Generate a non-existent chat ID + let non_existent_chat_id = Uuid::new_v4(); + + // Set up the test server with our endpoint + let app = Router::new() + .route("/chats/:id/sharing", put(update_chat_sharing_rest_handler)) + .layer(Extension(AuthenticatedUser { + id: user.id, + email: user.email.clone(), + org_id: None, + })); + + let client = TestClient::new(app); + + // Make the request + let payload = json!([ + { + "email": "test@example.com", + "role": "Editor" + } + ]); + + let response = client + .put(&format!("/chats/{}/sharing", non_existent_chat_id)) + .json(&payload) + .send() + .await?; + + // Assert the response is not found + response.assert_status_not_found()?; + + Ok(()) +} + +#[tokio::test] +async fn test_update_chat_sharing_invalid_email() -> Result<()> { + // Set up test fixtures + let mut fixture = FixtureBuilder::new().await?; + + // Create a test user + let user = fixture.create_user().await?; + + // Create a test chat owned by the user + let chat = fixture.create_chat(&user.id).await?; + + // Create a manual permission so our test user has Owner access to the chat + create_share( + chat.id, + AssetType::Chat, + user.id, + IdentityType::User, + AssetPermissionRole::Owner, + user.id, + ) + .await?; + + // Set up the test server with our endpoint + let app = Router::new() + .route("/chats/:id/sharing", put(update_chat_sharing_rest_handler)) + .layer(Extension(AuthenticatedUser { + id: user.id, + email: user.email.clone(), + org_id: None, + })); + + let client = TestClient::new(app); + + // Make the request with an invalid email format + let payload = json!([ + { + "email": "invalid-email", // Missing @ symbol + "role": "Editor" + } + ]); + + let response = client + .put(&format!("/chats/{}/sharing", chat.id)) + .json(&payload) + .send() + .await?; + + // Assert the response is bad request + response.assert_status_bad_request()?; + + Ok(()) +} \ No newline at end of file