From 90d2ff04d5a1e05402ae5b18fff7efe299964f06 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Thu, 20 Aug 2026 10:44:11 +0800 Subject: [PATCH 1/4] fix(tasks): stop account deletion Cloud cleanup calls --- crates/api/src/bin/task_worker.rs | 209 +++--------------- .../api/tests/user_account_deletion_tests.rs | 26 +-- .../src/repositories/user_repository.rs | 47 +--- crates/services/src/conversation/ports.rs | 13 -- crates/services/src/conversation/service.rs | 54 ----- crates/services/src/user/ports.rs | 4 + 6 files changed, 49 insertions(+), 304 deletions(-) diff --git a/crates/api/src/bin/task_worker.rs b/crates/api/src/bin/task_worker.rs index 0784f2d7..45e37f29 100644 --- a/crates/api/src/bin/task_worker.rs +++ b/crates/api/src/bin/task_worker.rs @@ -3,13 +3,10 @@ use async_trait::async_trait; use axum::{routing::get, Json, Router}; use chrono::{Duration, Utc}; use serde::Serialize; -use services::conversation::ports::ConversationService; -use services::response::service::OpenAIProxy; use services::tasks::{ AccountDeletionTaskPayload, CleanupCanceledInstancesTaskPayload, NoopTaskPayload, TaskExecutor, }; use services::user::ports::{AccountDeletionError, AccountDeletionStatus, UserRepository}; -use services::vpc::{initialize_vpc_credentials, VpcAuthConfig}; use services::{agent::ports::AgentService, UserId}; use std::sync::Arc; @@ -30,7 +27,6 @@ struct DefaultTaskExecutor { db_pool: database::DbPool, agent_service: Arc, user_repository: Arc, - conversation_service: Arc, } const ACCOUNT_DELETION_LEASE_SECONDS: i64 = 300; @@ -48,21 +44,15 @@ fn progress_string_ids(progress: &serde_json::Value, key: &str) -> Vec { .unwrap_or_default() } -fn progress_deleted_conversation_ids(progress: &serde_json::Value) -> Vec { - progress_string_ids(progress, "cloud_deleted_conversation_ids") -} - -fn progress_deleted_file_ids(progress: &serde_json::Value) -> Vec { - progress_string_ids(progress, "cloud_deleted_file_ids") -} - -fn build_account_deletion_progress( - conversation_ids: &[String], - file_ids: &[String], -) -> serde_json::Value { +/// Safely preserves legacy Cloud cleanup markers for in-flight deletion records. +/// +/// The retired Cloud Conversations and Files APIs are no longer contacted, and +/// these fields are not prerequisites for finalization. Keeping a normalized +/// copy lets old progress records be retried without trusting malformed JSON. +fn normalize_legacy_account_deletion_progress(progress: &serde_json::Value) -> serde_json::Value { serde_json::json!({ - "cloud_deleted_conversation_ids": conversation_ids, - "cloud_deleted_file_ids": file_ids, + "cloud_deleted_conversation_ids": progress_string_ids(progress, "cloud_deleted_conversation_ids"), + "cloud_deleted_file_ids": progress_string_ids(progress, "cloud_deleted_file_ids"), }) } @@ -289,7 +279,7 @@ impl TaskExecutor for DefaultTaskExecutor { .mark_account_deletion_failed_needs_review( request.id, last_error.clone(), - request.progress.clone(), + normalize_legacy_account_deletion_progress(&request.progress), ) .await .context("failed to mark account deletion as failed_needs_review")?; @@ -302,112 +292,11 @@ impl TaskExecutor for DefaultTaskExecutor { return Ok(()); } - let mut cloud_deleted_conversation_ids = - progress_deleted_conversation_ids(&request.progress); - let mut cloud_deleted_file_ids = progress_deleted_file_ids(&request.progress); - let mut cloud_deleted_set = cloud_deleted_conversation_ids - .iter() - .cloned() - .collect::>(); - let mut cloud_deleted_file_set = cloud_deleted_file_ids - .iter() - .cloned() - .collect::>(); - - let conversation_ids = self - .user_repository - .list_owned_conversation_ids(request.user_id) - .await - .context("failed to list account conversations")?; - - for conversation_id in conversation_ids { - if cloud_deleted_set.contains(&conversation_id) { - continue; - } - - if let Err(err) = self - .conversation_service - .delete_conversation_from_provider(&conversation_id) - .await - { - let progress = build_account_deletion_progress( - &cloud_deleted_conversation_ids, - &cloud_deleted_file_ids, - ); - let last_error = - format!("failed to delete cloud conversation {conversation_id}: {err}"); - self.user_repository - .mark_account_deletion_retrying(request.id, last_error.clone(), progress) - .await - .context("failed to mark account deletion retrying")?; - anyhow::bail!(last_error); - } - - cloud_deleted_set.insert(conversation_id.clone()); - cloud_deleted_conversation_ids.push(conversation_id); - self.user_repository - .update_account_deletion_progress( - request.id, - build_account_deletion_progress( - &cloud_deleted_conversation_ids, - &cloud_deleted_file_ids, - ), - ACCOUNT_DELETION_LEASE_SECONDS, - ) - .await - .context("failed to update account deletion progress")?; - } - - let file_ids = self - .user_repository - .list_owned_file_ids(request.user_id) - .await - .context("failed to list account files")?; - - for file_id in file_ids { - if cloud_deleted_file_set.contains(&file_id) { - continue; - } - - if let Err(err) = self - .conversation_service - .delete_file_from_provider(&file_id) - .await - { - let progress = build_account_deletion_progress( - &cloud_deleted_conversation_ids, - &cloud_deleted_file_ids, - ); - let last_error = format!("failed to delete provider file {file_id}: {err}"); - self.user_repository - .mark_account_deletion_retrying(request.id, last_error.clone(), progress) - .await - .context("failed to mark account deletion retrying")?; - anyhow::bail!(last_error); - } - - cloud_deleted_file_set.insert(file_id.clone()); - cloud_deleted_file_ids.push(file_id); - self.user_repository - .update_account_deletion_progress( - request.id, - build_account_deletion_progress( - &cloud_deleted_conversation_ids, - &cloud_deleted_file_ids, - ), - ACCOUNT_DELETION_LEASE_SECONDS, - ) - .await - .context("failed to update account deletion file progress")?; - } + let legacy_progress = normalize_legacy_account_deletion_progress(&request.progress); match self .user_repository - .delete_user_account( - request.user_id, - &cloud_deleted_conversation_ids, - &cloud_deleted_file_ids, - ) + .delete_user_account(request.user_id, &[], &[]) .await { Ok(()) | Err(AccountDeletionError::UserNotFound) => { @@ -426,16 +315,12 @@ impl TaskExecutor for DefaultTaskExecutor { err @ (AccountDeletionError::BlockingSubscriptions { .. } | AccountDeletionError::InstancesNotDeleted { .. }), ) => { - let progress = build_account_deletion_progress( - &cloud_deleted_conversation_ids, - &cloud_deleted_file_ids, - ); let last_error = err.to_string(); self.user_repository .mark_account_deletion_failed_needs_review( request.id, last_error.clone(), - progress, + legacy_progress, ) .await .context( @@ -444,13 +329,9 @@ impl TaskExecutor for DefaultTaskExecutor { Err(anyhow!(last_error)) } Err(err) => { - let progress = build_account_deletion_progress( - &cloud_deleted_conversation_ids, - &cloud_deleted_file_ids, - ); let last_error = err.to_string(); self.user_repository - .mark_account_deletion_retrying(request.id, last_error.clone(), progress) + .mark_account_deletion_retrying(request.id, last_error.clone(), legacy_progress) .await .context("failed to mark account deletion retrying after finalization error")?; Err(anyhow!(last_error)) @@ -507,46 +388,6 @@ async fn main() -> anyhow::Result<()> { config.agent.non_tee_agent_url_pattern.clone(), )); - let vpc_auth_config = if config.vpc_auth.is_configured() { - let base_url = config.openai.base_url.as_ref().ok_or_else(|| { - anyhow!("OPENAI_BASE_URL is required when VPC authentication is configured") - })?; - let shared_secret = config - .vpc_auth - .read_shared_secret() - .ok_or_else(|| anyhow!("Failed to read VPC shared secret"))?; - Some(VpcAuthConfig { - client_id: config.vpc_auth.client_id.clone(), - shared_secret, - base_url: base_url.clone(), - }) - } else { - None - }; - - let static_api_key = if vpc_auth_config.is_none() { - Some(config.openai.api_key.clone()) - } else { - None - }; - let vpc_credentials_service = initialize_vpc_credentials( - vpc_auth_config, - db.app_config_repository() as Arc, - static_api_key, - ) - .await?; - - let mut proxy_service = OpenAIProxy::new(vpc_credentials_service); - if let Some(base_url) = config.openai.base_url.clone() { - proxy_service = proxy_service.with_base_url(base_url); - } - let conversation_service = Arc::new( - services::conversation::service::ConversationServiceImpl::new( - db.conversation_repository(), - Arc::new(proxy_service), - ), - ); - let aws_config = api::tasks::load_aws_sdk_config(region).await; let sqs_client = aws_sdk_sqs::Client::new(&aws_config); @@ -554,7 +395,6 @@ async fn main() -> anyhow::Result<()> { db_pool: db.pool().clone(), agent_service, user_repository: db.user_repository(), - conversation_service, }); let health_port = tasks.port; @@ -595,3 +435,26 @@ async fn main() -> anyhow::Result<()> { let _ = shutdown_tx.send(()); result } + +#[cfg(test)] +mod tests { + use super::normalize_legacy_account_deletion_progress; + use serde_json::json; + + #[test] + fn legacy_cloud_cleanup_progress_is_inert_and_safe_to_resume() { + let normalized = normalize_legacy_account_deletion_progress(&json!({ + "cloud_deleted_conversation_ids": ["conv_already_deleted", 42, null], + "cloud_deleted_file_ids": "not-an-array", + "unrelated_legacy_value": { "keep": false }, + })); + + assert_eq!( + normalized, + json!({ + "cloud_deleted_conversation_ids": ["conv_already_deleted"], + "cloud_deleted_file_ids": [], + }) + ); + } +} diff --git a/crates/api/tests/user_account_deletion_tests.rs b/crates/api/tests/user_account_deletion_tests.rs index f5e619a1..532aa6c7 100644 --- a/crates/api/tests/user_account_deletion_tests.rs +++ b/crates/api/tests/user_account_deletion_tests.rs @@ -2,7 +2,7 @@ mod common; use common::{create_test_server_and_db, mock_login, TestServerConfig}; use http::{HeaderName, HeaderValue}; -use services::user::ports::{AccountDeletionError, UserRepository}; +use services::user::ports::UserRepository; use uuid::Uuid; fn auth_header(token: &str) -> (HeaderName, HeaderValue) { @@ -245,7 +245,7 @@ async fn delete_account_request_creates_pending_state_and_blocks_access() { assert_eq!(profile_response.status_code(), 403); db.user_repository() - .delete_user_account(user.id, std::slice::from_ref(&conversation_id), &[]) + .delete_user_account(user.id, &[], &[]) .await .expect("finalize delete account"); db.user_repository() @@ -386,7 +386,7 @@ async fn account_deletion_request_reports_insert_vs_existing() { } #[tokio::test] -async fn delete_account_requires_cloud_file_cleanup() { +async fn delete_account_finalizes_legacy_local_file_rows_without_cloud_cleanup() { let (server, db) = create_test_server_and_db(TestServerConfig::default()).await; let email = format!("delete_account_file_guard_{}@test.org", Uuid::new_v4()); let token = mock_login(&server, &email).await; @@ -409,29 +409,17 @@ async fn delete_account_requires_cloud_file_cleanup() { .await .expect("insert file"); - let err = db - .user_repository() + db.user_repository() .delete_user_account(user.id, &[], &[]) .await - .expect_err("file guard should block finalization"); - match err { - AccountDeletionError::FileCleanupIncomplete { file_ids } => { - assert_eq!(file_ids, vec![file_id.clone()]); - } - other => panic!("unexpected error: {other:?}"), - } + .expect("finalize without retired Cloud file cleanup"); assert!(db .user_repository() .get_user(user.id) .await - .expect("get user after blocked finalization") - .is_some()); - - db.user_repository() - .delete_user_account(user.id, &[], std::slice::from_ref(&file_id)) - .await - .expect("finalize after file provider cleanup"); + .expect("get user after finalization") + .is_none()); let file_count: i64 = client .query_one("SELECT COUNT(*) FROM files WHERE id = $1", &[&file_id]) diff --git a/crates/database/src/repositories/user_repository.rs b/crates/database/src/repositories/user_repository.rs index b5407039..5786e75a 100644 --- a/crates/database/src/repositories/user_repository.rs +++ b/crates/database/src/repositories/user_repository.rs @@ -6,7 +6,6 @@ use services::user::ports::{ BanType, LinkedOAuthAccount, OAuthProvider, User, UserRepository, }; use services::UserId; -use std::collections::HashSet; use tokio_postgres::GenericClient; use uuid::Uuid; @@ -249,8 +248,8 @@ impl UserRepository for PostgresUserRepository { async fn delete_user_account( &self, user_id: UserId, - cloud_deleted_conversation_ids: &[String], - cloud_deleted_file_ids: &[String], + _cloud_deleted_conversation_ids: &[String], + _cloud_deleted_file_ids: &[String], ) -> Result<(), AccountDeletionError> { let mut client = self.pool.get().await.map_err(anyhow::Error::from)?; let tx = client.transaction().await.map_err(anyhow::Error::from)?; @@ -273,48 +272,6 @@ impl UserRepository for PostgresUserRepository { .map(|row| row.get("provider_user_id")) .collect(); - let verified_conversation_ids: HashSet<&str> = cloud_deleted_conversation_ids - .iter() - .map(String::as_str) - .collect(); - let current_conversation_rows = tx - .query( - "SELECT id FROM conversations WHERE user_id = $1 ORDER BY id", - &[&user_id], - ) - .await - .map_err(anyhow::Error::from)?; - let missing_cloud_deletes: Vec = current_conversation_rows - .into_iter() - .map(|row| row.get::<_, String>("id")) - .filter(|id| !verified_conversation_ids.contains(id.as_str())) - .collect(); - if !missing_cloud_deletes.is_empty() { - return Err(AccountDeletionError::ConversationCleanupIncomplete { - conversation_ids: missing_cloud_deletes, - }); - } - - let verified_file_ids: HashSet<&str> = - cloud_deleted_file_ids.iter().map(String::as_str).collect(); - let current_file_rows = tx - .query( - "SELECT id FROM files WHERE user_id = $1 ORDER BY id", - &[&user_id], - ) - .await - .map_err(anyhow::Error::from)?; - let missing_cloud_file_deletes: Vec = current_file_rows - .into_iter() - .map(|row| row.get::<_, String>("id")) - .filter(|id| !verified_file_ids.contains(id.as_str())) - .collect(); - if !missing_cloud_file_deletes.is_empty() { - return Err(AccountDeletionError::FileCleanupIncomplete { - file_ids: missing_cloud_file_deletes, - }); - } - if let Some(ref email) = user_email { tx.execute( "DELETE FROM conversation_shares diff --git a/crates/services/src/conversation/ports.rs b/crates/services/src/conversation/ports.rs index 32990025..856a8e37 100644 --- a/crates/services/src/conversation/ports.rs +++ b/crates/services/src/conversation/ports.rs @@ -282,19 +282,6 @@ pub trait ConversationService: Send + Sync { conversation_id: &str, user_id: UserId, ) -> Result; - - /// Delete a conversation from the upstream Cloud API without changing local DB state. - async fn delete_conversation_from_provider( - &self, - conversation_id: &str, - ) -> Result; - - /// Delete a file from the upstream provider without changing local DB state. - /// Treats 404 (already deleted) as success for idempotent retry. - async fn delete_file_from_provider( - &self, - file_id: &str, - ) -> Result; } #[derive(Debug, Clone)] diff --git a/crates/services/src/conversation/service.rs b/crates/services/src/conversation/service.rs index 79207e16..7b3df007 100644 --- a/crates/services/src/conversation/service.rs +++ b/crates/services/src/conversation/service.rs @@ -161,60 +161,6 @@ impl ConversationService for ConversationServiceImpl { Ok(deleted) } - - async fn delete_conversation_from_provider( - &self, - conversation_id: &str, - ) -> Result { - self.delete_conversation_from_openai(conversation_id).await - } - - async fn delete_file_from_provider( - &self, - file_id: &str, - ) -> Result { - let path = format!("files/{}", file_id); - - tracing::debug!("Deleting file from provider: {}", path); - - let response = self - .openai_proxy - .forward_request(Method::DELETE, &path, http::HeaderMap::new(), None) - .await - .map_err(|e| ConversationError::ApiError(e.to_string()))?; - - if response.status == 404 { - tracing::info!( - "File {} already deleted from provider (404), treating as success", - file_id - ); - return Ok(serde_json::Value::Null); - } - - if response.status != 200 { - tracing::error!( - "Provider API returned status {} for file deletion {}", - response.status, - file_id - ); - return Err(ConversationError::ApiError(format!( - "Provider API returned status {}", - response.status - ))); - } - - let body_bytes: Bytes = response - .body - .try_collect::>() - .await - .map_err(|e| ConversationError::ApiError(format!("Failed to read response: {}", e)))? - .into_iter() - .flatten() - .collect(); - - serde_json::from_slice(&body_bytes) - .map_err(|e| ConversationError::ApiError(format!("Failed to parse JSON: {}", e))) - } } impl ConversationServiceImpl { diff --git a/crates/services/src/user/ports.rs b/crates/services/src/user/ports.rs index ca9311b6..4aca9607 100644 --- a/crates/services/src/user/ports.rs +++ b/crates/services/src/user/ports.rs @@ -179,6 +179,10 @@ pub trait UserRepository: Send + Sync { ) -> anyhow::Result; /// Delete a user account and direct PII rows while preserving audit and billing data. + /// + /// The legacy Cloud cleanup ID lists are accepted for compatibility with + /// in-flight account-deletion records, but are no longer required before + /// local finalization. async fn delete_user_account( &self, user_id: UserId, From 8e85d15c74195a04d56ecb4af44ccd23fcf5b4b0 Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:03:23 +0800 Subject: [PATCH 2/4] refactor(api): remove retired stateful implementation --- crates/api/src/consts.rs | 1 - crates/api/src/main.rs | 24 - crates/api/src/models.rs | 36 - crates/api/src/routes/api.rs | 2494 +--------------- crates/api/src/state.rs | 4 - crates/api/src/validation.rs | 177 -- crates/api/tests/common.rs | 24 - crates/database/src/lib.rs | 30 +- .../repositories/conversation_repository.rs | 172 -- .../conversation_share_repository.rs | 914 ------ .../src/repositories/file_repository.rs | 232 -- crates/database/src/repositories/mod.rs | 6 - crates/services/src/conversation/mod.rs | 3 - crates/services/src/conversation/ports.rs | 370 --- crates/services/src/conversation/service.rs | 420 --- .../src/conversation/share_service.rs | 2517 ----------------- crates/services/src/file/mod.rs | 2 - crates/services/src/file/ports.rs | 83 - crates/services/src/file/service.rs | 169 -- crates/services/src/lib.rs | 2 - 20 files changed, 34 insertions(+), 7646 deletions(-) delete mode 100644 crates/database/src/repositories/conversation_repository.rs delete mode 100644 crates/database/src/repositories/conversation_share_repository.rs delete mode 100644 crates/database/src/repositories/file_repository.rs delete mode 100644 crates/services/src/conversation/mod.rs delete mode 100644 crates/services/src/conversation/ports.rs delete mode 100644 crates/services/src/conversation/service.rs delete mode 100644 crates/services/src/conversation/share_service.rs delete mode 100644 crates/services/src/file/mod.rs delete mode 100644 crates/services/src/file/ports.rs delete mode 100644 crates/services/src/file/service.rs diff --git a/crates/api/src/consts.rs b/crates/api/src/consts.rs index 918d3941..ef2c3dc5 100644 --- a/crates/api/src/consts.rs +++ b/crates/api/src/consts.rs @@ -1,7 +1,6 @@ pub const SYSTEM_PROMPT_MAX_LEN: usize = 64 * 1024; /// Maximum limit for admin list/top endpoints (users list, usage top). pub const LIST_USERS_LIMIT_MAX: i64 = 100; -pub const LIST_FILES_LIMIT_MAX: i64 = 10_000; /// Maximum size for request body (50 MB) /// Prevents DoS attacks from unbounded memory allocation diff --git a/crates/api/src/main.rs b/crates/api/src/main.rs index bbcdf46f..1322e850 100644 --- a/crates/api/src/main.rs +++ b/crates/api/src/main.rs @@ -11,9 +11,6 @@ use services::{ agent::proxy::AgentProxy, analytics::AnalyticsServiceImpl, auth::{EmailAuthServiceImpl, OAuthServiceImpl}, - conversation::service::ConversationServiceImpl, - conversation::share_service::ConversationShareServiceImpl, - file::service::FileServiceImpl, metrics::{MockMetricsService, OtlpMetricsService}, model::service::ModelServiceImpl, response::service::OpenAIProxy, @@ -105,9 +102,6 @@ async fn main() -> anyhow::Result<()> { let user_repo = db.user_repository(); let session_repo = db.session_repository(); let oauth_repo = db.oauth_repository(); - let conversation_repo = db.conversation_repository(); - let conversation_share_repo = db.conversation_share_repository(); - let file_repo = db.file_repository(); let user_settings_repo = db.user_settings_repository(); let app_config_repo = db.app_config_repository(); let near_nonce_repo = db.near_nonce_repository(); @@ -198,21 +192,6 @@ async fn main() -> anyhow::Result<()> { } let proxy_service = Arc::new(proxy_service); - // Initialize conversation service - let conversation_service = Arc::new(ConversationServiceImpl::new( - conversation_repo, - proxy_service.clone(), - )); - - let conversation_share_service = Arc::new(ConversationShareServiceImpl::new( - db.conversation_repository(), - conversation_share_repo, - user_repo.clone(), - )); - - // Initialize file service - let file_service = Arc::new(FileServiceImpl::new(file_repo, proxy_service.clone())); - // Initialize system configs service (needed by agent service) tracing::info!("Initializing system configs service..."); let system_configs_service = Arc::new( @@ -400,9 +379,6 @@ async fn main() -> anyhow::Result<()> { subscription_service, session_repository: session_repo, proxy_service, - conversation_service, - conversation_share_service, - file_service, agent_service, agent_repository: agent_repo, agent_proxy_service, diff --git a/crates/api/src/models.rs b/crates/api/src/models.rs index 7cc38c4d..b5751ba5 100644 --- a/crates/api/src/models.rs +++ b/crates/api/src/models.rs @@ -1,7 +1,6 @@ use crate::consts::SYSTEM_PROMPT_MAX_LEN; use crate::ApiError; use serde::{Deserialize, Serialize}; -use services::file::ports::FileData; use services::system_configs::ports::{ AgentHostingConfig, AutoRouteConfig, CreditsConfig, InstanceDefaultsConfig, SubscriptionPlanConfig, @@ -875,41 +874,6 @@ impl TryFrom for services::system_configs::ports::Pa } } -/// File list response with pagination -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct FileListResponse { - /// Always "list" - pub object: String, - /// List of files (without `object` field per item) - pub data: Vec, - /// First file ID in the list - #[serde(skip_serializing_if = "Option::is_none")] - pub first_id: Option, - /// Last file ID in the list - #[serde(skip_serializing_if = "Option::is_none")] - pub last_id: Option, - /// Whether there are more files available - pub has_more: bool, -} - -/// File get response with `object` field -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct FileGetResponse { - /// Always "file" - pub object: String, - #[serde(flatten)] - pub file: FileData, -} - -impl From for FileGetResponse { - fn from(file: FileData) -> Self { - Self { - object: "file".to_string(), - file, - } - } -} - // ============================================================================ // Agent Models // ============================================================================ diff --git a/crates/api/src/routes/api.rs b/crates/api/src/routes/api.rs index 8d3a7d0a..c98e840a 100644 --- a/crates/api/src/routes/api.rs +++ b/crates/api/src/routes/api.rs @@ -1,6 +1,5 @@ use crate::consts::{ - LIST_FILES_LIMIT_MAX, MAX_DECOMPRESSED_RESPONSE_BODY_SIZE, MAX_REQUEST_BODY_SIZE, - MAX_RESPONSE_BODY_SIZE, + MAX_DECOMPRESSED_RESPONSE_BODY_SIZE, MAX_REQUEST_BODY_SIZE, MAX_RESPONSE_BODY_SIZE, }; use crate::middleware::auth::{AuthenticatedApiKey, AuthenticatedUser}; use crate::usage_parsing::{ @@ -29,20 +28,12 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use services::analytics::{ActivityType, RecordActivityRequest}; use services::consts::MODEL_PUBLIC_DEFAULT; -use services::conversation::ports::{ - ConversationError, SharePermission, ShareRecipient, ShareRecipientKind, ShareTarget, -}; -use services::file::ports::FileError; -use services::metrics::consts::{ - METRIC_CONVERSATION_CREATED, METRIC_FILE_UPLOADED, METRIC_RESPONSE_CREATED, -}; -use services::response::ports::ProxyResponse; +use services::metrics::consts::METRIC_RESPONSE_CREATED; use services::subscription::ports::SubscriptionError; use services::user::ports::{BanType, OAuthProvider}; use services::UserId; use std::io::Read; use utoipa::ToSchema; -use uuid::Uuid; /// Minimum required NEAR balance (1 NEAR in yoctoNEAR: 10^24) const MIN_NEAR_BALANCE: u128 = 1_000_000_000_000_000_000_000_000; @@ -75,9 +66,6 @@ pub const SUBSCRIPTION_REQUIRED_ERROR_MESSAGE: &str = /// OpenAPI tag constants for API documentation mod openapi_tags { - pub const CONVERSATIONS: &str = "Conversations"; - pub const SHARE_GROUPS: &str = "Share Groups"; - pub const FILES: &str = "Files"; pub const PROXY: &str = "Proxy"; } @@ -85,10 +73,6 @@ mod openapi_tags { mod openapi_errors { pub const BAD_REQUEST: &str = "Bad request"; pub const UNAUTHORIZED: &str = "Unauthorized"; - pub const ACCESS_DENIED: &str = "Access denied"; - pub const CONVERSATION_NOT_FOUND: &str = "Conversation not found"; - pub const SHARE_GROUP_NOT_FOUND: &str = "Share group not found"; - pub const CONVERSATION_OR_SHARE_NOT_FOUND: &str = "Conversation or share not found"; pub const OPENAI_API_ERROR: &str = "OpenAI API error"; } @@ -307,16 +291,6 @@ pub fn create_api_router( .merge(session_auth_routes) } -/// Type of resource to track in the response -#[allow(dead_code)] // Retained only until #381 removes the retired stateful implementation. -enum TrackableResource { - /// New conversation - records metrics - Conversation, - /// Updated conversation - tracks in DB but does NOT record metrics - ConversationUpdate, - File, -} - #[derive(Serialize, Deserialize, ToSchema)] pub struct ErrorResponse { pub error: String, @@ -499,2073 +473,42 @@ fn normalize_stateless_response_body(body: &mut serde_json::Value) -> Result<(), #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct InvalidProxyPathSegment; -fn validate_proxy_path_segment(value: &str) -> Result<(), InvalidProxyPathSegment> { - validate_proxy_path_segment_variant(value)?; - - let decoded = urlencoding::decode(value).map_err(|_| InvalidProxyPathSegment)?; - if decoded.contains('%') { - return Err(InvalidProxyPathSegment); - } - validate_proxy_path_segment_variant(&decoded)?; - - Ok(()) -} - -fn validate_proxy_path_segment_variant(value: &str) -> Result<(), InvalidProxyPathSegment> { - if value.is_empty() - || value == "." - || value == ".." - || value.contains('/') - || value.contains('\\') - || value.contains('?') - || value.contains('#') - || value.chars().any(char::is_control) - { - return Err(InvalidProxyPathSegment); - } - - Ok(()) -} - -fn invalid_proxy_path_segment_response(field_name: &str) -> Response { - ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: format!("Invalid {field_name}: unsafe path segment"), - }), - ) - .into_response() -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct ShareRecipientPayload { - pub kind: ShareRecipientKind, - pub value: String, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -#[serde(tag = "mode", rename_all = "snake_case")] -pub enum ShareTargetPayload { - Direct { - recipients: Vec, - }, - Group { - group_id: Uuid, - }, - Organization { - email_pattern: String, - }, - Public, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct CreateConversationShareRequest { - pub permission: SharePermission, - pub target: ShareTargetPayload, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct ConversationShareResponse { - pub id: Uuid, - pub conversation_id: String, - pub permission: SharePermission, - pub share_type: String, - pub recipient: Option, - pub group_id: Option, - pub org_email_pattern: Option, - pub created_at: chrono::DateTime, - pub updated_at: chrono::DateTime, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct OwnerInfo { - pub user_id: String, - pub name: Option, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct ConversationSharesListResponse { - pub is_owner: bool, - pub can_share: bool, - /// Whether the user can send messages (has write access) - pub can_write: bool, - pub shares: Vec, - /// Owner information for displaying author names on messages - pub owner: Option, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct CreateShareGroupRequest { - pub name: String, - pub members: Vec, -} - -#[derive(Debug, Serialize, Deserialize, ToSchema)] -pub struct UpdateShareGroupRequest { - pub name: Option, - pub members: Option>, -} - -#[derive(Debug, Serialize, ToSchema)] -pub struct ShareGroupResponse { - pub id: Uuid, - pub name: String, - pub members: Vec, - pub created_at: chrono::DateTime, - pub updated_at: chrono::DateTime, -} - -impl From for ShareRecipient { - fn from(payload: ShareRecipientPayload) -> Self { - ShareRecipient { - kind: payload.kind, - value: payload.value, - } - } -} - -impl From for ShareRecipientPayload { - fn from(recipient: ShareRecipient) -> Self { - ShareRecipientPayload { - kind: recipient.kind, - value: recipient.value, - } - } -} - -#[allow(dead_code)] -fn to_share_response( - share: services::conversation::ports::ConversationShare, -) -> ConversationShareResponse { - ConversationShareResponse { - id: share.id, - conversation_id: share.conversation_id, - permission: share.permission, - share_type: share.share_type.as_str().to_string(), - recipient: share.recipient.map(ShareRecipientPayload::from), - group_id: share.group_id, - org_email_pattern: share.org_email_pattern, - created_at: share.created_at, - updated_at: share.updated_at, - } -} - -#[allow(dead_code)] -fn to_share_group_response(group: services::conversation::ports::ShareGroup) -> ShareGroupResponse { - ShareGroupResponse { - id: group.id, - name: group.name, - members: group - .members - .into_iter() - .map(ShareRecipientPayload::from) - .collect(), - created_at: group.created_at, - updated_at: group.updated_at, - } -} - -/// Raw query parameters for listing files -#[derive(Serialize, Deserialize, Debug, ToSchema)] -pub struct ListFilesParams { - pub after: Option, - pub limit: Option, - pub order: Option, - pub purpose: Option, -} - -/// Validated and normalized list files parameters -#[derive(Debug)] -pub struct ValidatedListFilesParams { - pub after: Option, - pub limit: i64, - pub order: String, - pub purpose: Option, -} - -impl ListFilesParams { - /// Validate query parameters and return normalized values (with defaults applied) - #[allow(dead_code)] - fn validate(self) -> Result)> { - // Apply default values - let limit = self.limit.unwrap_or(LIST_FILES_LIMIT_MAX); - if !(1..=LIST_FILES_LIMIT_MAX).contains(&limit) { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: format!( - "Invalid limit parameter. Must be between 1 and {}", - LIST_FILES_LIMIT_MAX - ), - }), - )); - } - - let order = self.order.unwrap_or_else(|| "desc".to_string()); - if order != "asc" && order != "desc" { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "Invalid order parameter. Must be 'asc' or 'desc'".to_string(), - }), - )); - } - - Ok(ValidatedListFilesParams { - after: self.after, - limit, - order, - purpose: self.purpose, - }) - } -} - -/// Create a conversation - forwards to OpenAI and tracks in DB -#[utoipa::path( - post, - path = "/v1/conversations", - tag = CONVERSATIONS, - request_body = serde_json::Value, - responses( - (status = 200, description = "Conversation created successfully", body = serde_json::Value), - (status = 400, description = BAD_REQUEST, body = ErrorResponse), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn create_conversation( - State(state): State, - Extension(user): Extension, - headers: HeaderMap, - request: Request, -) -> Result { - tracing::info!( - "create_conversation called for user_id={}, session_id={}", - user.user_id, - user.session_id - ); - - // Extract body - let body_bytes = axum::body::to_bytes(request.into_body(), MAX_REQUEST_BODY_SIZE) - .await - .map_err(|e| { - tracing::error!( - "Failed to read request body for user_id={}: {}", - user.user_id, - e - ); - ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: format!("Failed to read request body: {e}"), - }), - ) - .into_response() - })?; - - tracing::debug!( - "create_conversation request body size: {} bytes for user_id={}", - body_bytes.len(), - user.user_id - ); - - tracing::debug!( - "Request body content redacted; body_size={} bytes", - body_bytes.len() - ); - - tracing::debug!( - "Forwarding conversation creation request to OpenAI for user_id={}", - user.user_id - ); - - // Forward to OpenAI - let proxy_response = state - .proxy_service - .forward_request( - Method::POST, - "conversations", - headers.clone(), - Some(body_bytes), - ) - .await - .map_err(|e| { - tracing::error!( - "OpenAI API error during conversation creation for user_id={}: {}", - user.user_id, - e - ); - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!("OpenAI API error: {e}"), - }), - ) - .into_response() - })?; - - handle_trackable_response( - &state, - &user, - proxy_response, - TrackableResource::Conversation, - ) - .await -} - -/// Update a conversation - validates user access then forwards to OpenAI and updates tracking -#[utoipa::path( - post, - path = "/v1/conversations/{conversation_id}", - tag = CONVERSATIONS, - params( - ("conversation_id" = String, Path, description = "ID of the conversation to update") - ), - request_body = serde_json::Value, - responses( - (status = 200, description = "Conversation updated successfully", body = serde_json::Value), - (status = 400, description = BAD_REQUEST, body = ErrorResponse), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = CONVERSATION_NOT_FOUND, body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn update_conversation( - State(state): State, - Extension(user): Extension, - Path(conversation_id): Path, - headers: HeaderMap, - request: Request, -) -> Result { - tracing::info!( - "update_conversation called for user_id={}, session_id={}, conversation_id={}", - user.user_id, - user.session_id, - conversation_id - ); - - // Validate user has access to the conversation - validate_user_conversation(&state, &user, &conversation_id, SharePermission::Write).await?; - - // Extract body - let body_bytes = extract_body_bytes(request).await?; - - tracing::debug!( - "update_conversation request body size: {} bytes for user_id={}", - body_bytes.len(), - user.user_id - ); - - tracing::debug!( - "Request body content redacted; body_size={} bytes", - body_bytes.len() - ); - - tracing::debug!( - "Forwarding conversation update request to OpenAI for user_id={}", - user.user_id - ); - - // Forward to OpenAI - let proxy_response = state - .proxy_service - .forward_request( - Method::POST, - &format!("conversations/{conversation_id}"), - headers.clone(), - Some(body_bytes), - ) - .await - .map_err(|e| { - tracing::error!( - "OpenAI API error during conversation update for user_id={}: {}", - user.user_id, - e - ); - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!("OpenAI API error: {e}"), - }), - ) - .into_response() - })?; - - // Track the updated conversation (don't fail the request if tracking fails) - // Use ConversationUpdate to avoid recording metrics for updates - handle_trackable_response( - &state, - &user, - proxy_response, - TrackableResource::ConversationUpdate, - ) - .await -} - -/// List all conversations for the authenticated user (fetches details from OpenAI client) -#[utoipa::path( - get, - path = "/v1/conversations", - tag = CONVERSATIONS, - responses( - (status = 200, description = "List of conversations retrieved successfully", body = Vec), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn list_conversations( - State(state): State, - Extension(user): Extension, -) -> Result { - tracing::info!("list_conversations called for user_id={}", user.user_id); - - let conversations = state - .conversation_service - .list_conversations(user.user_id) - .await - .map_err(|e| { - tracing::error!( - "Failed to list conversations for user_id={}: {}", - user.user_id, - e - ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: format!("Failed to list conversations: {e}"), - }), - ) - .into_response() - })?; - - tracing::info!( - "Retrieved {} conversations for user_id={}", - conversations.len(), - user.user_id - ); - - Ok(Json(conversations).into_response()) -} - -/// Get a conversation - validates user access or public share and fetches details via service/OpenAI -/// Works with optional authentication - authenticated users get their access checked, -/// unauthenticated users can only access publicly shared conversations -/// -/// # Authentication -/// This endpoint supports **optional authentication**: -/// - **With authentication**: Returns conversation if user owns it or has been granted access via sharing -/// - **Without authentication**: Returns conversation only if it has been publicly shared -/// -/// This allows public sharing of conversations while maintaining access control for private conversations. -#[utoipa::path( - get, - path = "/v1/conversations/{conversation_id}", - tag = CONVERSATIONS, - params( - ("conversation_id" = String, Path, description = "ID of the conversation to retrieve") - ), - responses( - (status = 200, description = "Conversation retrieved successfully", body = serde_json::Value), - (status = 403, description = "Access denied - conversation not accessible to this user or not publicly shared"), - (status = 404, description = CONVERSATION_NOT_FOUND) - ), - security( - (), // Optional - no auth required for publicly shared conversations - ("session_token" = []) // Optional - session token for authenticated access - ) -)] -#[allow(dead_code)] -async fn get_conversation( - State(state): State, - Extension(user): Extension>, - Path(conversation_id): Path, - headers: HeaderMap, -) -> Result, Response> { - tracing::info!( - "get_conversation called for user_id={:?}, conversation_id={}", - user.as_ref().map(|u| u.user_id), - conversation_id - ); - - // Check user access OR public share access - validate_conversation_access_optional_auth( - &state, - user.as_ref(), - &conversation_id, - SharePermission::Read, - ) - .await?; - - let conversation = - fetch_conversation_from_proxy(&state, &conversation_id, headers.clone()).await?; - - Ok(Json(conversation)) -} - -/// Delete a conversation for the authenticated user -#[utoipa::path( - delete, - path = "/v1/conversations/{conversation_id}", - tag = CONVERSATIONS, - params( - ("conversation_id" = String, Path, description = "ID of the conversation to delete") - ), - responses( - (status = 200, description = "Conversation deleted successfully", body = serde_json::Value), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = CONVERSATION_NOT_FOUND, body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn delete_conversation( - State(state): State, - Extension(user): Extension, - Path(conversation_id): Path, -) -> Result { - tracing::info!( - "delete_conversation called for user_id={}, conversation_id={}", - user.user_id, - conversation_id - ); - - validate_owner_conversation(&state, &user, &conversation_id).await?; - - // Delete from DB and OpenAI - let deleted = state - .conversation_service - .delete_conversation(&conversation_id, user.user_id) - .await - .map_err(|e| { - tracing::error!( - "Failed to delete conversation {} for user_id={}: {}", - conversation_id, - user.user_id, - e - ); - - let (status, msg) = match e { - ConversationError::NotFound => { - (StatusCode::NOT_FOUND, "Conversation not found".to_string()) - } - ConversationError::ApiError(msg) => { - (StatusCode::BAD_GATEWAY, format!("OpenAI API error: {msg}")) - } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to delete conversation".to_string(), - ), - }; - - (status, Json(ErrorResponse { error: msg })).into_response() - })?; - - Ok(Json(deleted).into_response()) -} - -/// Create a share for a conversation -#[utoipa::path( - post, - path = "/v1/conversations/{conversation_id}/shares", - tag = CONVERSATIONS, - params( - ("conversation_id" = String, Path, description = "ID of the conversation to share") - ), - request_body = CreateConversationShareRequest, - responses( - (status = 200, description = "Share(s) created successfully", body = Vec), - (status = 400, description = "Bad request - invalid recipients or empty list", body = ErrorResponse), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = CONVERSATION_NOT_FOUND, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn create_conversation_share( - State(state): State, - Extension(user): Extension, - Path(conversation_id): Path, - Json(request): Json, -) -> Result>, Response> { - let target = match request.target { - ShareTargetPayload::Direct { recipients } => { - if recipients.is_empty() { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "Recipients list cannot be empty".to_string(), - }), - ) - .into_response()); - } - - // Validate all recipients before processing - for recipient in &recipients { - crate::validation::validate_share_recipient(&recipient.kind, &recipient.value) - .map_err(|error| { - ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: format!( - "Invalid {} recipient '{}': {}", - match recipient.kind { - ShareRecipientKind::Email => "email", - ShareRecipientKind::NearAccount => "NEAR account", - }, - recipient.value, - error - ), - }), - ) - .into_response() - })?; - } - - ShareTarget::Direct( - recipients - .into_iter() - .map(ShareRecipient::from) - .collect::>(), - ) - } - ShareTargetPayload::Group { group_id } => ShareTarget::Group(group_id), - ShareTargetPayload::Organization { email_pattern } => { - let validated_pattern = crate::validation::validate_org_email_pattern(&email_pattern) - .map_err(|error| { - (StatusCode::BAD_REQUEST, Json(ErrorResponse { error })).into_response() - })?; - - ShareTarget::Organization(validated_pattern) - } - ShareTargetPayload::Public => ShareTarget::Public, - }; - - let shares = state - .conversation_share_service - .create_share(user.user_id, &conversation_id, request.permission, target) - .await - .map_err(map_share_error)?; - - // Record share activity in analytics - if let Err(e) = state - .analytics_service - .record_activity(RecordActivityRequest { - user_id: user.user_id, - activity_type: ActivityType::Share, - auth_method: None, - metadata: Some(serde_json::json!({ - "conversation_id": conversation_id, - "share_count": shares.len(), - "permission": request.permission.as_str(), - })), - }) - .await - { - tracing::warn!("Failed to record analytics for share creation: {}", e); - } - - Ok(Json( - shares - .into_iter() - .map(to_share_response) - .collect::>(), - )) -} - -/// List all shares for a conversation -#[utoipa::path( - get, - path = "/v1/conversations/{conversation_id}/shares", - tag = CONVERSATIONS, - params( - ("conversation_id" = String, Path, description = "ID of the conversation to list shares for") - ), - responses( - (status = 200, description = "List of shares retrieved successfully", body = ConversationSharesListResponse), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = CONVERSATION_NOT_FOUND, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn list_conversation_shares( - State(state): State, - Extension(user): Extension, - Path(conversation_id): Path, -) -> Result, Response> { - // Get the actual owner of the conversation from the database - let owner_id = state - .conversation_service - .get_conversation_owner(&conversation_id) - .await - .map_err(map_share_error)?; - - // Check if current user is the owner - let is_owner = owner_id.map(|o| o == user.user_id).unwrap_or(false); - - // Check if user has write access (owner OR shared with write permission) - let has_write_access = is_owner - || state - .conversation_share_service - .ensure_access(&conversation_id, user.user_id, SharePermission::Write) - .await - .is_ok(); - - // Get owner info for displaying author names on messages - let owner_info = if let Some(owner_user_id) = owner_id { - state - .user_service - .get_user_profile(owner_user_id) - .await - .ok() - .map(|profile| OwnerInfo { - user_id: owner_user_id.to_string(), - name: profile.user.name, - }) - } else { - None - }; - - // List shares - owners and users with write access can see shares - let shares = if has_write_access { - if let Some(owner_user_id) = owner_id { - state - .conversation_share_service - .list_shares(owner_user_id, &conversation_id) - .await - .map_err(map_share_error)? - } else { - Vec::new() - } - } else { - Vec::new() - }; - - Ok(Json(ConversationSharesListResponse { - is_owner, - can_share: has_write_access, - can_write: has_write_access, - shares: shares.into_iter().map(to_share_response).collect(), - owner: owner_info, - })) -} - -/// Delete a share for a conversation -#[utoipa::path( - delete, - path = "/v1/conversations/{conversation_id}/shares/{share_id}", - tag = CONVERSATIONS, - params( - ("conversation_id" = String, Path, description = "ID of the conversation"), - ("share_id" = Uuid, Path, description = "ID of the share to delete") - ), - responses( - (status = 204, description = "Share deleted successfully"), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = CONVERSATION_OR_SHARE_NOT_FOUND, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn delete_conversation_share( - State(state): State, - Extension(user): Extension, - Path((conversation_id, share_id)): Path<(String, Uuid)>, -) -> Result { - state - .conversation_share_service - .delete_share(user.user_id, &conversation_id, share_id) - .await - .map_err(map_share_error)?; - - Ok(StatusCode::NO_CONTENT.into_response()) -} - -/// Create a share group -#[utoipa::path( - post, - path = "/v1/share-groups", - tag = SHARE_GROUPS, - request_body = CreateShareGroupRequest, - responses( - (status = 200, description = "Share group created successfully", body = ShareGroupResponse), - (status = 400, description = "Bad request - empty name or members", body = ErrorResponse), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn create_share_group( - State(state): State, - Extension(user): Extension, - Json(request): Json, -) -> Result, Response> { - if request.name.trim().is_empty() { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "Group name cannot be empty".to_string(), - }), - ) - .into_response()); - } - - if request.members.is_empty() { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "Group must include at least one member".to_string(), - }), - ) - .into_response()); - } - - // Validate all members before processing - for member in &request.members { - crate::validation::validate_share_recipient(&member.kind, &member.value).map_err( - |error| { - ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: format!( - "Invalid {} member '{}': {}", - match member.kind { - ShareRecipientKind::Email => "email", - ShareRecipientKind::NearAccount => "NEAR account", - }, - member.value, - error - ), - }), - ) - .into_response() - }, - )?; - } - - let members = request - .members - .into_iter() - .map(ShareRecipient::from) - .collect(); - - let group = state - .conversation_share_service - .create_group(user.user_id, &request.name, members) - .await - .map_err(map_share_error)?; - - Ok(Json(to_share_group_response(group))) -} - -/// List all share groups for the authenticated user -#[utoipa::path( - get, - path = "/v1/share-groups", - tag = SHARE_GROUPS, - responses( - (status = 200, description = "List of share groups retrieved successfully", body = Vec), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 500, description = "Internal server error", body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn list_share_groups( - State(state): State, - Extension(user): Extension, -) -> Result>, Response> { - // Get user profile to extract email and NEAR accounts for membership matching - let user_profile = state - .user_service - .get_user_profile(user.user_id) - .await - .map_err(|e| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(ErrorResponse { - error: format!("Failed to get user profile: {}", e), - }), - ) - .into_response() - })?; - - // Build member identifiers from user's email and linked NEAR accounts - let mut member_identifiers = vec![ShareRecipient { - kind: ShareRecipientKind::Email, - value: user_profile.user.email.to_lowercase(), - }]; - - // Add NEAR accounts from linked accounts - for account in &user_profile.linked_accounts { - if account.provider == services::user::ports::OAuthProvider::Near { - member_identifiers.push(ShareRecipient { - kind: ShareRecipientKind::NearAccount, - value: account.provider_user_id.clone(), - }); - } - } - - let groups = state - .conversation_share_service - .list_accessible_groups(user.user_id, &member_identifiers) - .await - .map_err(map_share_error)?; - - Ok(Json( - groups - .into_iter() - .map(to_share_group_response) - .collect::>(), - )) -} - -/// Update a share group -#[utoipa::path( - patch, - path = "/v1/share-groups/{group_id}", - tag = SHARE_GROUPS, - params( - ("group_id" = Uuid, Path, description = "ID of the share group to update") - ), - request_body = UpdateShareGroupRequest, - responses( - (status = 200, description = "Share group updated successfully", body = ShareGroupResponse), - (status = 400, description = "Bad request - empty name or members", body = ErrorResponse), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = SHARE_GROUP_NOT_FOUND, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn update_share_group( - State(state): State, - Extension(user): Extension, - Path(group_id): Path, - Json(request): Json, -) -> Result, Response> { - if matches!(request.name.as_deref(), Some(name) if name.trim().is_empty()) { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "Group name cannot be empty".to_string(), - }), - ) - .into_response()); - } - - if matches!(request.members.as_ref(), Some(members) if members.is_empty()) { - return Err(( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: "Group members cannot be empty".to_string(), - }), - ) - .into_response()); - } - - // Validate all members before processing - if let Some(ref members) = request.members { - for member in members { - crate::validation::validate_share_recipient(&member.kind, &member.value).map_err( - |error| { - ( - StatusCode::BAD_REQUEST, - Json(ErrorResponse { - error: format!( - "Invalid {} member '{}': {}", - match member.kind { - ShareRecipientKind::Email => "email", - ShareRecipientKind::NearAccount => "NEAR account", - }, - member.value, - error - ), - }), - ) - .into_response() - }, - )?; - } - } - - let members = request.members.map(|members| { - members - .into_iter() - .map(ShareRecipient::from) - .collect::>() - }); - - let group = state - .conversation_share_service - .update_group(user.user_id, group_id, request.name, members) - .await - .map_err(map_share_error)?; - - Ok(Json(to_share_group_response(group))) -} - -/// Delete a share group -#[utoipa::path( - delete, - path = "/v1/share-groups/{group_id}", - tag = SHARE_GROUPS, - params( - ("group_id" = Uuid, Path, description = "ID of the share group to delete") - ), - responses( - (status = 204, description = "Share group deleted successfully"), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = SHARE_GROUP_NOT_FOUND, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn delete_share_group( - State(state): State, - Extension(user): Extension, - Path(group_id): Path, -) -> Result { - state - .conversation_share_service - .delete_group(user.user_id, group_id) - .await - .map_err(map_share_error)?; - - Ok(StatusCode::NO_CONTENT.into_response()) -} - -#[derive(Serialize, ToSchema)] -pub struct SharedConversationInfo { - conversation_id: String, - permission: SharePermission, - /// Conversation title (None if fetch failed) - title: Option, - /// Conversation created_at timestamp (None if fetch failed) - created_at: Option, - /// Error message if conversation details couldn't be fetched - error: Option, -} - -/// Maximum concurrent requests when fetching conversation details -#[allow(dead_code)] -const SHARED_CONVERSATIONS_FETCH_CONCURRENCY: usize = 10; - -/// List conversations shared with the authenticated user -#[utoipa::path( - get, - path = "/v1/shared-with-me", - tag = SHARE_GROUPS, - responses( - (status = 200, description = "List of shared conversations retrieved successfully", body = Vec), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn list_shared_with_me( - State(state): State, - Extension(user): Extension, - headers: HeaderMap, -) -> Result>, Response> { - let shared = state - .conversation_share_service - .list_shared_with_me(user.user_id) - .await - .map_err(map_share_error)?; - - if shared.is_empty() { - return Ok(Json(vec![])); - } - - // Fetch conversation details with concurrency limit - let semaphore = std::sync::Arc::new(tokio::sync::Semaphore::new( - SHARED_CONVERSATIONS_FETCH_CONCURRENCY, - )); - - let fetch_tasks: Vec<_> = shared - .into_iter() - .map(|(conversation_id, permission)| { - let state = state.clone(); - let headers = headers.clone(); - let semaphore = semaphore.clone(); - - async move { - let _permit = semaphore.acquire().await; - let result = fetch_conversation_from_proxy(&state, &conversation_id, headers).await; - - match result { - Ok(conversation) => { - let title = conversation - .get("metadata") - .and_then(|m| m.get("title")) - .and_then(|t| t.as_str()) - .map(|s| s.to_string()); - let created_at = conversation.get("created_at").and_then(|c| c.as_i64()); - - SharedConversationInfo { - conversation_id, - permission, - title, - created_at, - error: None, - } - } - Err(_) => SharedConversationInfo { - conversation_id, - permission, - title: None, - created_at: None, - error: Some("Failed to fetch conversation details".to_string()), - }, - } - } - }) - .collect(); - - let results = futures::future::join_all(fetch_tasks).await; - - Ok(Json(results)) -} - -/// Create items in a conversation -#[utoipa::path( - post, - path = "/v1/conversations/{conversation_id}/items", - tag = CONVERSATIONS, - params( - ("conversation_id" = String, Path, description = "ID of the conversation to add items to") - ), - request_body = serde_json::Value, - responses( - (status = 200, description = "Items created successfully"), - (status = 400, description = BAD_REQUEST, body = ErrorResponse), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = CONVERSATION_NOT_FOUND, body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn create_conversation_items( - State(state): State, - Extension(user): Extension, - Path(conversation_id): Path, - mut headers: HeaderMap, - request: Request, -) -> Result { - tracing::info!( - "create_conversation_items called for user_id={}, session_id={}", - user.user_id, - user.session_id - ); - - validate_user_conversation(&state, &user, &conversation_id, SharePermission::Write).await?; - - // Fetch user profile for author metadata - let user_profile = state.user_service.get_user_profile(user.user_id).await.ok(); - let author_name = user_profile.as_ref().and_then(|p| p.user.name.clone()); - - // Extract body - let body_bytes = extract_body_bytes(request).await?; - - tracing::debug!( - "create_conversation_items request body size: {} bytes for user_id={}", - body_bytes.len(), - user.user_id - ); - - // Parse and modify body to inject author metadata - // This allows shared conversations to show who sent each message. - // Author tracking is handled by cloud-api. - let modified_body = - if let Ok(mut body_json) = serde_json::from_slice::(&body_bytes) { - // Inject author metadata - let mut metadata = body_json - .get("metadata") - .and_then(|m| m.as_object()) - .cloned() - .unwrap_or_default(); - - metadata.insert( - "author_id".to_string(), - serde_json::Value::String(user.user_id.to_string()), - ); - if let Some(name) = author_name.as_ref() { - metadata.insert( - "author_name".to_string(), - serde_json::Value::String(name.clone()), - ); - } - body_json["metadata"] = serde_json::Value::Object(metadata); - - serde_json::to_vec(&body_json).unwrap_or_else(|_| body_bytes.to_vec()) - } else { - body_bytes.to_vec() - }; - - // Set content-length header to match modified body - // usize::to_string() only produces ASCII digits, which are always valid for HeaderValue - let content_length = HeaderValue::from_str(&modified_body.len().to_string()) - .expect("usize to string conversion always produces valid HeaderValue"); - headers.insert(CONTENT_LENGTH, content_length); - - tracing::debug!( - "Forwarding conversation items creation request to OpenAI for user_id={}", - user.user_id - ); - - // Forward to OpenAI - let proxy_response = state - .proxy_service - .forward_request( - Method::POST, - &format!("conversations/{conversation_id}/items"), - headers.clone(), - Some(Bytes::from(modified_body)), - ) - .await - .map_err(|e| { - tracing::error!( - "OpenAI API error during conversation items creation for user_id={}: {}", - user.user_id, - e - ); - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!("OpenAI API error: {e}"), - }), - ) - .into_response() - })?; - - build_response( - proxy_response.status, - proxy_response.headers, - Body::from_stream(proxy_response.body), - ) - .await -} - -/// List conversation items - works with optional authentication -/// Authenticated users get their access checked, unauthenticated users can only access public conversations -/// -/// # Authentication -/// This endpoint supports **optional authentication**: -/// - **With authentication**: Returns items if user owns the conversation or has been granted access via sharing -/// - **Without authentication**: Returns items only if the conversation has been publicly shared -/// -/// This allows public sharing of conversation content while maintaining access control for private conversations. -#[utoipa::path( - get, - path = "/v1/conversations/{conversation_id}/items", - tag = CONVERSATIONS, - params( - ("conversation_id" = String, Path, description = "ID of the conversation to list items from") - ), - responses( - (status = 200, description = "Conversation items retrieved successfully"), - (status = 403, description = "Access denied - conversation not accessible to this user or not publicly shared"), - (status = 404, description = CONVERSATION_NOT_FOUND) - ), - security( - (), // Optional - no auth required for publicly shared conversations - ("session_token" = []) // Optional - session token for authenticated access - ) -)] -#[allow(dead_code)] -async fn list_conversation_items( - State(state): State, - Extension(user): Extension>, - Path(conversation_id): Path, - headers: HeaderMap, -) -> Result { - tracing::info!( - "list_conversation_items called for user_id={:?}, conversation_id={}", - user.as_ref().map(|u| u.user_id), - conversation_id - ); - - // Check user access OR public share access - validate_conversation_access_optional_auth( - &state, - user.as_ref(), - &conversation_id, - SharePermission::Read, - ) - .await?; - - tracing::debug!( - "Forwarding conversation items list request to OpenAI for user_id={:?}", - user.as_ref().map(|u| u.user_id) - ); - - // Forward to OpenAI - let proxy_response = state - .proxy_service - .forward_request( - Method::GET, - &format!("conversations/{conversation_id}/items"), - headers.clone(), - None, - ) - .await - .map_err(|e| { - tracing::error!("OpenAI API error during conversation items list: {}", e); - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!("OpenAI API error: {e}"), - }), - ) - .into_response() - })?; - - build_response( - proxy_response.status, - proxy_response.headers, - Body::from_stream(proxy_response.body), - ) - .await -} - -/// Pin a conversation -#[utoipa::path( - post, - path = "/v1/conversations/{conversation_id}/pin", - tag = CONVERSATIONS, - params( - ("conversation_id" = String, Path, description = "ID of the conversation to pin") - ), - responses( - (status = 200, description = "Conversation pinned successfully"), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = CONVERSATION_NOT_FOUND, body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn pin_conversation( - State(state): State, - Extension(user): Extension, - Path(conversation_id): Path, - headers: HeaderMap, -) -> Result { - tracing::info!( - "pin_conversation called for user_id={}, session_id={}, conversation_id={}", - user.user_id, - user.session_id, - conversation_id - ); - - validate_user_conversation(&state, &user, &conversation_id, SharePermission::Write).await?; - - tracing::debug!( - "Forwarding conversation pin request to OpenAI for user_id={}", - user.user_id - ); - - // Forward to OpenAI - let proxy_response = state - .proxy_service - .forward_request( - Method::POST, - &format!("conversations/{conversation_id}/pin"), - headers.clone(), - None, - ) - .await - .map_err(|e| { - tracing::error!( - "OpenAI API error during conversation pin for user_id={}: {}", - user.user_id, - e - ); - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!("OpenAI API error: {e}"), - }), - ) - .into_response() - })?; - - build_response( - proxy_response.status, - proxy_response.headers, - Body::from_stream(proxy_response.body), - ) - .await -} - -/// Unpin a conversation -#[utoipa::path( - delete, - path = "/v1/conversations/{conversation_id}/pin", - tag = CONVERSATIONS, - params( - ("conversation_id" = String, Path, description = "ID of the conversation to unpin") - ), - responses( - (status = 200, description = "Conversation unpinned successfully"), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = CONVERSATION_NOT_FOUND, body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn unpin_conversation( - State(state): State, - Extension(user): Extension, - Path(conversation_id): Path, - headers: HeaderMap, -) -> Result { - tracing::info!( - "unpin_conversation called for user_id={}, session_id={}, conversation_id={}", - user.user_id, - user.session_id, - conversation_id - ); - - validate_user_conversation(&state, &user, &conversation_id, SharePermission::Write).await?; - - tracing::debug!( - "Forwarding conversation unpin request to OpenAI for user_id={}", - user.user_id - ); - - // Forward to OpenAI - let proxy_response = state - .proxy_service - .forward_request( - Method::DELETE, - &format!("conversations/{conversation_id}/pin"), - headers.clone(), - None, - ) - .await - .map_err(|e| { - tracing::error!( - "OpenAI API error during conversation unpin for user_id={}: {}", - user.user_id, - e - ); - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!("OpenAI API error: {e}"), - }), - ) - .into_response() - })?; - - build_response( - proxy_response.status, - proxy_response.headers, - Body::from_stream(proxy_response.body), - ) - .await -} - -/// Archive a conversation -#[utoipa::path( - post, - path = "/v1/conversations/{conversation_id}/archive", - tag = CONVERSATIONS, - params( - ("conversation_id" = String, Path, description = "ID of the conversation to archive") - ), - responses( - (status = 200, description = "Conversation archived successfully"), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = CONVERSATION_NOT_FOUND, body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn archive_conversation( - State(state): State, - Extension(user): Extension, - Path(conversation_id): Path, - headers: HeaderMap, -) -> Result { - tracing::info!( - "archive_conversation called for user_id={}, session_id={}, conversation_id={}", - user.user_id, - user.session_id, - conversation_id - ); - - validate_user_conversation(&state, &user, &conversation_id, SharePermission::Write).await?; - - tracing::debug!( - "Forwarding conversation archive request to OpenAI for user_id={}", - user.user_id - ); - - // Forward to OpenAI - let proxy_response = state - .proxy_service - .forward_request( - Method::POST, - &format!("conversations/{conversation_id}/archive"), - headers.clone(), - None, - ) - .await - .map_err(|e| { - tracing::error!( - "OpenAI API error during conversation archive for user_id={}: {}", - user.user_id, - e - ); - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!("OpenAI API error: {e}"), - }), - ) - .into_response() - })?; - - build_response( - proxy_response.status, - proxy_response.headers, - Body::from_stream(proxy_response.body), - ) - .await -} - -/// Unarchive a conversation -#[utoipa::path( - delete, - path = "/v1/conversations/{conversation_id}/archive", - tag = CONVERSATIONS, - params( - ("conversation_id" = String, Path, description = "ID of the conversation to unarchive") - ), - responses( - (status = 200, description = "Conversation unarchived successfully"), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = CONVERSATION_NOT_FOUND, body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn unarchive_conversation( - State(state): State, - Extension(user): Extension, - Path(conversation_id): Path, - headers: HeaderMap, -) -> Result { - tracing::info!( - "unarchive_conversation called for user_id={}, session_id={}, conversation_id={}", - user.user_id, - user.session_id, - conversation_id - ); - - validate_user_conversation(&state, &user, &conversation_id, SharePermission::Write).await?; - - tracing::debug!( - "Forwarding conversation unarchive request to OpenAI for user_id={}", - user.user_id - ); - - // Forward to OpenAI - let proxy_response = state - .proxy_service - .forward_request( - Method::DELETE, - &format!("conversations/{conversation_id}/archive"), - headers.clone(), - None, - ) - .await - .map_err(|e| { - tracing::error!( - "OpenAI API error during conversation unarchive for user_id={}: {}", - user.user_id, - e - ); - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!("OpenAI API error: {e}"), - }), - ) - .into_response() - })?; - - build_response( - proxy_response.status, - proxy_response.headers, - Body::from_stream(proxy_response.body), - ) - .await -} - -/// Clone a conversation -#[utoipa::path( - post, - path = "/v1/conversations/{conversation_id}/clone", - tag = CONVERSATIONS, - params( - ("conversation_id" = String, Path, description = "ID of the conversation to clone") - ), - responses( - (status = 200, description = "Conversation cloned successfully", body = serde_json::Value), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = CONVERSATION_NOT_FOUND, body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn clone_conversation( - State(state): State, - Extension(user): Extension, - Path(conversation_id): Path, - headers: HeaderMap, -) -> Result { - tracing::info!( - "clone_conversation called for user_id={}, session_id={}, conversation_id={}", - user.user_id, - user.session_id, - conversation_id - ); - - // Validate user has access to the source conversation OR it's publicly shared - // (read access is sufficient for cloning) - validate_user_or_public_conversation(&state, &user, &conversation_id, SharePermission::Read) - .await?; - - tracing::debug!( - "Forwarding conversation clone request to OpenAI for user_id={}", - user.user_id - ); - - // Forward to OpenAI - let proxy_response = state - .proxy_service - .forward_request( - Method::POST, - &format!("conversations/{conversation_id}/clone"), - headers.clone(), - None, - ) - .await - .map_err(|e| { - tracing::error!( - "OpenAI API error during conversation clone for user_id={}: {}", - user.user_id, - e - ); - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!("OpenAI API error: {e}"), - }), - ) - .into_response() - })?; - - handle_trackable_response( - &state, - &user, - proxy_response, - TrackableResource::Conversation, - ) - .await -} - -/// Upload a file - forwards to OpenAI and tracks in DB -#[utoipa::path( - post, - path = "/v1/files", - tag = FILES, - request_body(content = Vec, content_type = "multipart/form-data"), - responses( - (status = 200, description = "File uploaded successfully", body = crate::models::FileGetResponse), - (status = 400, description = BAD_REQUEST, body = ErrorResponse), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn upload_file( - State(state): State, - Extension(user): Extension, - headers: HeaderMap, - request: Request, -) -> Result { - tracing::info!( - "upload_file called for user_id={}, session_id={}", - user.user_id, - user.session_id - ); - - // Extract body - let body_bytes = extract_body_bytes(request).await?; - - tracing::debug!( - "upload_file request body size: {} bytes for user_id={}", - body_bytes.len(), - user.user_id - ); - - tracing::debug!( - "Forwarding file upload request to OpenAI for user_id={}", - user.user_id - ); - - // Forward to OpenAI - let proxy_response = state - .proxy_service - .forward_request(Method::POST, "files", headers.clone(), Some(body_bytes)) - .await - .map_err(|e| { - tracing::error!( - "OpenAI API error during file upload for user_id={}: {}", - user.user_id, - e - ); - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!("OpenAI API error: {e}"), - }), - ) - .into_response() - })?; - - handle_trackable_response(&state, &user, proxy_response, TrackableResource::File).await -} - -/// List all files for the authenticated user (fetches details from OpenAI) -#[utoipa::path( - get, - path = "/v1/files", - tag = FILES, - params( - ("after" = Option, Query, description = "File ID to start listing after"), - ("limit" = Option, Query, description = "Maximum number of files to return"), - ("order" = Option, Query, description = "Sort order: 'asc' or 'desc'"), - ("purpose" = Option, Query, description = "Filter by file purpose") - ), - responses( - (status = 200, description = "List of files retrieved successfully", body = crate::models::FileListResponse), - (status = 400, description = "Bad request - invalid query parameters", body = ErrorResponse), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 404, description = "File not found", body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn list_files( - State(state): State, - Extension(user): Extension, - axum::extract::Query(params): axum::extract::Query, -) -> Result, Response> { - tracing::info!("list_files called for user_id={}", user.user_id); - - // Validate and normalize query parameters - let validated = params.validate().map_err(|e| e.into_response())?; - - let (files, has_more) = state - .file_service - .list_files( - user.user_id, - validated.after.clone(), - validated.limit, - &validated.order, - validated.purpose.clone(), - ) - .await - .map_err(|e| { - tracing::error!("Failed to list files for user_id={}: {}", user.user_id, e); - let (status, error) = match e { - FileError::NotFound => (StatusCode::NOT_FOUND, "File not found".to_string()), - FileError::ApiError(msg) => { - (StatusCode::BAD_GATEWAY, format!("OpenAI API error: {msg}")) - } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to list files: {e}"), - ), - }; - (status, Json(ErrorResponse { error })).into_response() - })?; - - // Extract first and last file IDs - let first_id = files.first().map(|f| f.id.clone()); - let last_id = files.last().map(|f| f.id.clone()); - - let response = crate::models::FileListResponse { - object: "list".to_string(), - data: files.into_iter().map(From::from).collect(), - first_id, - last_id, - has_more, - }; - - Ok(Json(response)) -} - -/// Get a file - validates user access and fetches from OpenAI -#[utoipa::path( - get, - path = "/v1/files/{file_id}", - tag = FILES, - params( - ("file_id" = String, Path, description = "ID of the file to retrieve") - ), - responses( - (status = 200, description = "File retrieved successfully", body = crate::models::FileGetResponse), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 404, description = "File not found", body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn get_file( - State(state): State, - Extension(user): Extension, - Path(file_id): Path, -) -> Result, Response> { - validate_proxy_path_segment(&file_id) - .map_err(|_| invalid_proxy_path_segment_response("file_id"))?; - - tracing::info!( - "get_file called for user_id={}, file_id={}", - user.user_id, - file_id - ); - - let file = state - .file_service - .get_file(&file_id, user.user_id) - .await - .map_err(|e| { - let (status, error) = match e { - FileError::NotFound => (StatusCode::NOT_FOUND, "File not found".to_string()), - FileError::ApiError(msg) => { - (StatusCode::BAD_GATEWAY, format!("OpenAI API error: {msg}")) - } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to get file: {e}"), - ), - }; - - (status, Json(ErrorResponse { error })).into_response() - })?; - - Ok(Json(file.into())) -} - -/// Delete a file - validates user access, deletes from OpenAI and DB -#[utoipa::path( - delete, - path = "/v1/files/{file_id}", - tag = FILES, - params( - ("file_id" = String, Path, description = "ID of the file to delete") - ), - responses( - (status = 200, description = "File deleted successfully", body = serde_json::Value), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 404, description = "File not found", body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn delete_file( - State(state): State, - Extension(user): Extension, - Path(file_id): Path, -) -> Result { - validate_proxy_path_segment(&file_id) - .map_err(|_| invalid_proxy_path_segment_response("file_id"))?; - - tracing::info!( - "delete_file called for user_id={}, file_id={}", - user.user_id, - file_id - ); - - // Delete from DB and OpenAI - let deleted = state - .file_service - .delete_file(&file_id, user.user_id) - .await - .map_err(|e| { - tracing::error!( - "Failed to delete file {} for user_id={}: {}", - file_id, - user.user_id, - e - ); - let (status, error_msg) = match e { - FileError::NotFound => (StatusCode::NOT_FOUND, "File not found".to_string()), - FileError::ApiError(msg) => { - (StatusCode::BAD_GATEWAY, format!("OpenAI API error: {msg}")) - } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to delete file: {e}"), - ), - }; - (status, Json(ErrorResponse { error: error_msg })).into_response() - })?; - - Ok(Json(deleted).into_response()) -} +fn validate_proxy_path_segment(value: &str) -> Result<(), InvalidProxyPathSegment> { + validate_proxy_path_segment_variant(value)?; -/// Get file content - validates user access and fetches content from OpenAI -#[utoipa::path( - get, - path = "/v1/files/{file_id}/content", - tag = FILES, - params( - ("file_id" = String, Path, description = "ID of the file to get content for") - ), - responses( - (status = 200, description = "File content retrieved successfully"), - (status = 401, description = UNAUTHORIZED, body = ErrorResponse), - (status = 403, description = ACCESS_DENIED, body = ErrorResponse), - (status = 404, description = "File not found", body = ErrorResponse), - (status = 502, description = OPENAI_API_ERROR, body = ErrorResponse) - ), - security( - ("session_token" = []) - ) -)] -#[allow(dead_code)] -async fn get_file_content( - State(state): State, - Extension(user): Extension, - Path(file_id): Path, - headers: HeaderMap, -) -> Result { - tracing::info!( - "get_file_content called for user_id={}, file_id={}", - user.user_id, - file_id - ); + let decoded = urlencoding::decode(value).map_err(|_| InvalidProxyPathSegment)?; + if decoded.contains('%') { + return Err(InvalidProxyPathSegment); + } + validate_proxy_path_segment_variant(&decoded)?; - validate_user_file(&state, &user, &file_id).await?; + Ok(()) +} - tracing::debug!( - "Forwarding file content request to OpenAI for user_id={}", - user.user_id - ); +fn validate_proxy_path_segment_variant(value: &str) -> Result<(), InvalidProxyPathSegment> { + if value.is_empty() + || value == "." + || value == ".." + || value.contains('/') + || value.contains('\\') + || value.contains('?') + || value.contains('#') + || value.chars().any(char::is_control) + { + return Err(InvalidProxyPathSegment); + } - // Forward to OpenAI - let proxy_response = state - .proxy_service - .forward_request( - Method::GET, - &format!("files/{file_id}/content"), - headers.clone(), - None, - ) - .await - .map_err(|e| { - tracing::error!( - "OpenAI API error during file content get for user_id={}: {}", - user.user_id, - e - ); - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!("OpenAI API error: {e}"), - }), - ) - .into_response() - })?; + Ok(()) +} - build_response( - proxy_response.status, - proxy_response.headers, - Body::from_stream(proxy_response.body), +fn invalid_proxy_path_segment_response(field_name: &str) -> Response { + ( + StatusCode::BAD_REQUEST, + Json(ErrorResponse { + error: format!("Invalid {field_name}: unsafe path segment"), + }), ) - .await + .into_response() } /// Proxy a single, stateless Responses request to Cloud API. @@ -4826,169 +2769,6 @@ async fn proxy_models( .await } -/// Helper function to handle response: buffer, parse, and track resource -#[allow(dead_code)] -async fn handle_trackable_response( - state: &crate::state::AppState, - user: &AuthenticatedUser, - proxy_response: ProxyResponse, - resource_type: TrackableResource, -) -> Result { - let status = proxy_response.status; - let response_headers = proxy_response.headers; - - let request_id_header = crate::middleware::request_id_header_name(); - let upstream_request_id_len = response_headers - .get(&request_id_header) - .and_then(|value| value.to_str().ok()) - .map(str::len); - tracing::debug!( - "Response metadata: status={} header_count={} has_x_request_id={} x_request_id_len={}", - status, - response_headers.len(), - upstream_request_id_len.is_some(), - upstream_request_id_len.unwrap_or(0) - ); - - // Buffer the response to extract the resource ID - let proxy_body = Body::from_stream(proxy_response.body); - let body_bytes: Bytes = to_bytes(proxy_body, MAX_RESPONSE_BODY_SIZE) - .await - .map_err(|e| { - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: format!("Failed to read response: {e}"), - }), - ) - .into_response() - })?; - - if !(200..300).contains(&status) { - return build_response(status, response_headers, Body::from(body_bytes)).await; - } - - // If successful, parse response and track resource (don't fail request if tracking fails) - - let decompressed_bytes = decompress_if_encoded(body_bytes.clone(), &response_headers) - .unwrap_or_else(|e| { - tracing::error!( - "Failed to decompress response for user_id={}: {}", - user.user_id, - e - ); - body_bytes.clone() - }); - - let Ok(response_json) = serde_json::from_slice::(&decompressed_bytes) else { - return build_response(status, response_headers, Body::from(body_bytes)).await; - }; - - let Some(id) = response_json - .get("id") - .and_then(|v| v.as_str()) - .map(ToString::to_string) - else { - return build_response(status, response_headers, Body::from(body_bytes)).await; - }; - - match resource_type { - TrackableResource::Conversation => { - if let Err(e) = state - .conversation_service - .track_conversation(&id, user.user_id) - .await - { - tracing::error!( - "Failed to track conversation {} for user {}: {}", - id, - user.user_id, - e - ); - } - - // Record metrics for conversation creation - state - .metrics_service - .record_count(METRIC_CONVERSATION_CREATED, 1, &[]); - - // Record analytics in database - if let Err(e) = state - .analytics_service - .record_activity(RecordActivityRequest { - user_id: user.user_id, - activity_type: ActivityType::Conversation, - auth_method: None, - metadata: Some(serde_json::json!({ "conversation_id": id })), - }) - .await - { - tracing::warn!( - "Failed to record analytics for conversation creation: {}", - e - ); - } - } - TrackableResource::ConversationUpdate => { - // Track conversation in DB but do NOT record metrics (this is an update, not creation) - if let Err(e) = state - .conversation_service - .track_conversation(&id, user.user_id) - .await - { - tracing::error!( - "Failed to track conversation update {} for user {}: {}", - id, - user.user_id, - e - ); - } - } - TrackableResource::File => { - match serde_json::from_value::(response_json) { - Ok(file_data) => { - if let Err(e) = state.file_service.track_file(file_data, user.user_id).await { - tracing::error!( - "Failed to track file {} for user {}: {}", - id, - user.user_id, - e - ); - } - - // Record metrics for file upload - state - .metrics_service - .record_count(METRIC_FILE_UPLOADED, 1, &[]); - - // Record analytics in database - if let Err(e) = state - .analytics_service - .record_activity(RecordActivityRequest { - user_id: user.user_id, - activity_type: ActivityType::FileUpload, - auth_method: None, - metadata: Some(serde_json::json!({ "file_id": id })), - }) - .await - { - tracing::warn!("Failed to record analytics for file upload: {}", e); - } - } - Err(e) => { - tracing::error!( - "Failed to parse file data from response for user {}: {}", - user.user_id, - e - ); - } - } - } - } - - build_response(status, response_headers, Body::from(body_bytes)).await -} - async fn build_response(status: u16, headers: HeaderMap, body: Body) -> Result { // Build the response let mut response = Response::builder() @@ -5019,216 +2799,6 @@ async fn build_response(status: u16, headers: HeaderMap, body: Body) -> Result Result<(), Response> { - validate_proxy_path_segment(conversation_id) - .map_err(|_| invalid_proxy_path_segment_response("conversation_id"))?; - - state - .conversation_share_service - .ensure_access(conversation_id, user.user_id, required_permission) - .await - .map_err(map_share_error) -} - -/// Validate user has access OR the conversation is publicly shared -#[allow(dead_code)] -async fn validate_user_or_public_conversation( - state: &crate::state::AppState, - user: &AuthenticatedUser, - conversation_id: &str, - required_permission: SharePermission, -) -> Result<(), Response> { - validate_proxy_path_segment(conversation_id) - .map_err(|_| invalid_proxy_path_segment_response("conversation_id"))?; - - // First check regular access - let user_access = state - .conversation_share_service - .ensure_access(conversation_id, user.user_id, required_permission) - .await; - - if user_access.is_ok() { - return Ok(()); - } - - // If no user access, check if publicly shared - state - .conversation_share_service - .get_public_access_by_conversation_id(conversation_id, required_permission) - .await - .map(|_| ()) - .map_err(map_share_error) -} - -/// Validate conversation access with optional authentication -/// - If user is authenticated: check their access (owner, shared, or public) -/// - If user is not authenticated: only check if publicly shared -#[allow(dead_code)] -async fn validate_conversation_access_optional_auth( - state: &crate::state::AppState, - user: Option<&AuthenticatedUser>, - conversation_id: &str, - required_permission: SharePermission, -) -> Result<(), Response> { - validate_proxy_path_segment(conversation_id) - .map_err(|_| invalid_proxy_path_segment_response("conversation_id"))?; - - if let Some(user) = user { - // User is authenticated - check their access or public share - validate_user_or_public_conversation(state, user, conversation_id, required_permission) - .await - } else { - // User is not authenticated - only public share is allowed - state - .conversation_share_service - .get_public_access_by_conversation_id(conversation_id, required_permission) - .await - .map(|_| ()) - .map_err(map_share_error) - } -} - -#[allow(dead_code)] -async fn validate_owner_conversation( - state: &crate::state::AppState, - user: &AuthenticatedUser, - conversation_id: &str, -) -> Result<(), Response> { - validate_proxy_path_segment(conversation_id) - .map_err(|_| invalid_proxy_path_segment_response("conversation_id"))?; - - state - .conversation_service - .access_conversation(conversation_id, user.user_id) - .await - .map_err(|e| { - let (status, error) = match e { - ConversationError::NotFound => { - (StatusCode::NOT_FOUND, "Conversation not found".to_string()) - } - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to get conversation: {}", e), - ), - }; - - (status, Json(ErrorResponse { error })).into_response() - }) -} - -#[allow(dead_code)] -fn map_share_error(error: ConversationError) -> Response { - let (status, message) = match error { - ConversationError::NotFound => { - (StatusCode::NOT_FOUND, "Conversation not found".to_string()) - } - ConversationError::AccessDenied => (StatusCode::FORBIDDEN, "Access denied".to_string()), - ConversationError::ApiError(msg) => (StatusCode::BAD_GATEWAY, msg), - ConversationError::DatabaseError(msg) => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to access conversation: {msg}"), - ), - ConversationError::InternalError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), - }; - - (status, Json(ErrorResponse { error: message })).into_response() -} - -#[allow(dead_code)] -async fn fetch_conversation_from_proxy( - state: &crate::state::AppState, - conversation_id: &str, - headers: HeaderMap, -) -> Result { - validate_proxy_path_segment(conversation_id) - .map_err(|_| invalid_proxy_path_segment_response("conversation_id"))?; - - fn bad_gateway(message: impl Into) -> Response { - ( - StatusCode::BAD_GATEWAY, - Json(ErrorResponse { - error: message.into(), - }), - ) - .into_response() - } - - let proxy_response = state - .proxy_service - .forward_request( - Method::GET, - &format!("conversations/{conversation_id}"), - headers, - None, - ) - .await - .map_err(|e| bad_gateway(format!("OpenAI API error: {e}")))?; - - let status = StatusCode::from_u16(proxy_response.status).unwrap_or(StatusCode::BAD_GATEWAY); - if !status.is_success() { - let reason = status - .canonical_reason() - .map(|r| format!(" ({r})")) - .unwrap_or_default(); - return Err(bad_gateway(format!( - "OpenAI API returned status {}{reason}", - status.as_u16() - ))); - } - - let proxy_body = Body::from_stream(proxy_response.body); - let body_bytes: Bytes = to_bytes(proxy_body, MAX_RESPONSE_BODY_SIZE) - .await - .map_err(|e| bad_gateway(format!("Failed to read response: {e}")))?; - - let decompressed_body = decompress_if_encoded(body_bytes.clone(), &proxy_response.headers) - .unwrap_or_else(|e| { - tracing::warn!( - "Failed to decompress conversation response body for conversation_id={}: {}", - conversation_id, - e - ); - body_bytes.clone() - }); - - let conversation: serde_json::Value = serde_json::from_slice(&decompressed_body) - .map_err(|e| bad_gateway(format!("Failed to parse JSON: {e}")))?; - - Ok(conversation) -} - -#[allow(dead_code)] -async fn validate_user_file( - state: &crate::state::AppState, - user: &AuthenticatedUser, - file_id: &str, -) -> Result<(), Response> { - validate_proxy_path_segment(file_id) - .map_err(|_| invalid_proxy_path_segment_response("file_id"))?; - - state - .file_service - .access_file(file_id, user.user_id) - .await - .map_err(|e| { - let (status, error) = match e { - FileError::NotFound => (StatusCode::NOT_FOUND, "File not found".to_string()), - _ => ( - StatusCode::INTERNAL_SERVER_ERROR, - format!("Failed to get file: {}", e), - ), - }; - - (status, Json(ErrorResponse { error })).into_response() - }) -} - /// Extract body bytes from a request async fn extract_body_bytes(request: Request) -> Result { tracing::debug!("Extracting body bytes from request"); diff --git a/crates/api/src/state.rs b/crates/api/src/state.rs index 237bb6fd..6520c6f8 100644 --- a/crates/api/src/state.rs +++ b/crates/api/src/state.rs @@ -53,10 +53,6 @@ pub struct AppState { pub session_repository: Arc, pub user_repository: Arc, pub proxy_service: Arc, - pub conversation_service: Arc, - pub conversation_share_service: - Arc, - pub file_service: Arc, pub agent_service: Arc, pub agent_repository: Arc, pub agent_proxy_service: Arc, diff --git a/crates/api/src/validation.rs b/crates/api/src/validation.rs index 35f6f01e..5e31d9c6 100644 --- a/crates/api/src/validation.rs +++ b/crates/api/src/validation.rs @@ -1,8 +1,6 @@ //! Validation utilities for API request data use url::{Host, Url}; -use near_api::AccountId; - /// Validates a URL for Stripe checkout/portal redirects. /// Requires https for production. Allows http only for loopback (localhost, 127.0.0.1, [::1]). pub fn validate_redirect_url(url_str: &str, field_name: &str) -> Result<(), String> { @@ -101,159 +99,10 @@ pub fn validate_email(email: &str) -> Result<(), String> { Ok(()) } -/// Validates a NEAR account ID format. -/// -/// NEAR account IDs must: -/// - Be parseable as a valid NEAR AccountId -/// - Follow NEAR account naming conventions (2-64 characters, alphanumeric or separators) -/// -/// # Arguments -/// * `account_id` - The NEAR account ID to validate -/// -/// # Returns -/// * `Ok(())` - Account ID is valid -/// * `Err(String)` - Error message describing why validation failed -/// -/// # Examples -/// ``` -/// use api::validation::validate_near_account; -/// -/// assert!(validate_near_account("alice.near").is_ok()); -/// assert!(validate_near_account("bob.testnet").is_ok()); -/// assert!(validate_near_account("test@invalid").is_err()); // Contains invalid character -/// assert!(validate_near_account("a").is_err()); // Too short (min 2 chars) -/// ``` -pub fn validate_near_account(account_id: &str) -> Result<(), String> { - let trimmed = account_id.trim(); - - if trimmed.is_empty() { - return Err("NEAR account ID cannot be empty".to_string()); - } - - // Try to parse as NEAR AccountId - match trimmed.parse::() { - Ok(_) => Ok(()), - Err(e) => Err(format!("Invalid NEAR account ID format: {}", e)), - } -} - -/// Validates an organization email pattern for conversation sharing. -/// -/// Email patterns must: -/// - Start with '@' or '%@' (wildcard prefix) -/// - Contain at least one '.' in the domain part -/// - Have minimum 3 characters after the '@' (e.g., @a.b) -/// -/// # Arguments -/// * `pattern` - The email pattern to validate (e.g., "@company.com" or "%@company.com") -/// -/// # Returns -/// * `Ok(String)` - Trimmed and validated email pattern -/// * `Err(String)` - Error message describing why validation failed -/// -/// # Examples -/// ``` -/// use api::validation::validate_org_email_pattern; -/// -/// assert!(validate_org_email_pattern("@company.com").is_ok()); -/// assert!(validate_org_email_pattern("%@company.com").is_ok()); // Normalized pattern -/// assert!(validate_org_email_pattern("@subdomain.company.com").is_ok()); -/// assert!(validate_org_email_pattern("company.com").is_err()); // Missing @ -/// assert!(validate_org_email_pattern("@company").is_err()); // Missing . -/// ``` -pub fn validate_org_email_pattern(pattern: &str) -> Result { - let trimmed = pattern.trim(); - - if trimmed.is_empty() { - return Err("Email pattern cannot be empty".to_string()); - } - - // Validate email pattern format (must start with @ or %@ and have valid domain) - // Accept both user-provided format (@company.com) and normalized format (%@company.com) - let domain_part = if let Some(stripped) = trimmed.strip_prefix("%@") { - stripped - } else if let Some(stripped) = trimmed.strip_prefix('@') { - stripped - } else { - return Err("Email pattern must start with @ (e.g., @company.com)".to_string()); - }; - - // Basic validation: domain should have at least one dot and minimum length - if domain_part.is_empty() || !domain_part.contains('.') || domain_part.len() < 3 { - return Err("Invalid email pattern. Must be in format @domain.com".to_string()); - } - - Ok(trimmed.to_string()) -} - -/// Validates a share recipient based on its kind. -/// -/// - For Email recipients: validates email format -/// - For NearAccount recipients: validates NEAR account ID format -/// -/// # Arguments -/// * `kind` - The recipient kind (Email or NearAccount) -/// * `value` - The recipient value to validate -/// -/// # Returns -/// * `Ok(())` - Recipient value is valid -/// * `Err(String)` - Error message describing why validation failed -pub fn validate_share_recipient( - kind: &services::conversation::ports::ShareRecipientKind, - value: &str, -) -> Result<(), String> { - match kind { - services::conversation::ports::ShareRecipientKind::Email => validate_email(value), - services::conversation::ports::ShareRecipientKind::NearAccount => { - validate_near_account(value) - } - } -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn test_valid_email_patterns() { - assert!(validate_org_email_pattern("@company.com").is_ok()); - assert!(validate_org_email_pattern("@subdomain.company.com").is_ok()); - assert!(validate_org_email_pattern(" @company.com ").is_ok()); // Trimmed - assert!(validate_org_email_pattern("@a.b.c").is_ok()); - // Normalized patterns (with %@ prefix) should also be valid - assert!(validate_org_email_pattern("%@company.com").is_ok()); - assert!(validate_org_email_pattern("%@subdomain.company.com").is_ok()); - assert!(validate_org_email_pattern(" %@company.com ").is_ok()); // Trimmed - } - - #[test] - fn test_invalid_email_patterns() { - // Empty - assert!(validate_org_email_pattern("").is_err()); - assert!(validate_org_email_pattern(" ").is_err()); - - // Missing @ - assert!(validate_org_email_pattern("company.com").is_err()); - - // Missing domain parts - assert!(validate_org_email_pattern("@").is_err()); - assert!(validate_org_email_pattern("@company").is_err()); - assert!(validate_org_email_pattern("@a").is_err()); - assert!(validate_org_email_pattern("@a.").is_err()); - - // Invalid normalized patterns (missing domain parts after %@) - assert!(validate_org_email_pattern("%@").is_err()); - assert!(validate_org_email_pattern("%@company").is_err()); - assert!(validate_org_email_pattern("%@a").is_err()); - } - - #[test] - fn test_trimming() { - let result = validate_org_email_pattern(" @company.com "); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), "@company.com"); - } - #[test] fn test_valid_emails() { assert!(validate_email("user@example.com").is_ok()); @@ -294,30 +143,4 @@ mod tests { assert!(validate_email("user @example.com").is_err()); assert!(validate_email("user@example .com").is_err()); } - - #[test] - fn test_valid_near_accounts() { - assert!(validate_near_account("alice.near").is_ok()); - assert!(validate_near_account("bob.testnet").is_ok()); - assert!(validate_near_account("contract.mainnet").is_ok()); - assert!(validate_near_account(" alice.near ").is_ok()); // Trimmed - } - - #[test] - fn test_invalid_near_accounts() { - // Empty - assert!(validate_near_account("").is_err()); - assert!(validate_near_account(" ").is_err()); - - // Invalid format - too short (NEAR accounts must be at least 2 characters) - assert!(validate_near_account("a").is_err()); - - // Invalid format - contains invalid characters - assert!(validate_near_account("test@invalid").is_err()); - assert!(validate_near_account("test#invalid").is_err()); - - // Invalid format - too long (NEAR accounts max 64 chars, but some patterns are invalid) - // Note: "invalid" and "too-short" might actually be valid NEAR account IDs - // So we test with clearly invalid patterns - } } diff --git a/crates/api/tests/common.rs b/crates/api/tests/common.rs index 29b1fcdf..71be0609 100644 --- a/crates/api/tests/common.rs +++ b/crates/api/tests/common.rs @@ -7,8 +7,6 @@ use axum_test::TestServer; use chrono::Duration; use serde_json::json; use services::analytics::AnalyticsServiceImpl; -use services::conversation::share_service::ConversationShareServiceImpl; -use services::file::service::FileServiceImpl; use services::metrics::MockMetricsService; use services::subscription::ports::{StripeClientPort, SubscriptionService}; use services::system_configs::ports::RateLimitConfig; @@ -129,9 +127,6 @@ async fn create_test_server_and_db_inner( let user_repo = db.user_repository(); let session_repo = db.session_repository(); let oauth_repo = db.oauth_repository(); - let conversation_repo = db.conversation_repository(); - let conversation_share_repo = db.conversation_share_repository(); - let file_repo = db.file_repository(); let user_settings_repo = db.user_settings_repository(); let model_repo = db.model_repository(); let system_configs_repo = db.system_configs_repository(); @@ -295,20 +290,6 @@ async fn create_test_server_and_db_inner( } let proxy_service = Arc::new(proxy_service); - // Initialize conversation service - let conversation_service = Arc::new( - services::conversation::service::ConversationServiceImpl::new( - conversation_repo, - proxy_service.clone(), - ), - ); - - let conversation_share_service = Arc::new(ConversationShareServiceImpl::new( - db.conversation_repository(), - conversation_share_repo, - user_repo.clone(), - )); - let mut admin_domains = test_config .admin_domains .clone() @@ -318,8 +299,6 @@ async fn create_test_server_and_db_inner( // Add `admin.org` as test admin domain admin_domains.push("admin.org".to_string()); - let file_service = Arc::new(FileServiceImpl::new(file_repo, proxy_service.clone())); - // Create metrics service (mock for tests) let metrics_service: Arc = Arc::new(MockMetricsService); @@ -384,9 +363,6 @@ async fn create_test_server_and_db_inner( vpc_credentials_service, user_repository: user_repo, proxy_service, - conversation_service, - conversation_share_service, - file_service, agent_service, agent_repository: agent_repo, agent_proxy_service, diff --git a/crates/database/src/lib.rs b/crates/database/src/lib.rs index b32602e7..c690c164 100644 --- a/crates/database/src/lib.rs +++ b/crates/database/src/lib.rs @@ -8,9 +8,8 @@ pub mod repositories; pub use pool::DbPool; pub use repositories::{ PostgresAgentRepository, PostgresAmlReportRepository, PostgresAnalyticsRepository, - PostgresAppConfigRepository, PostgresBiMetricsRepository, PostgresConversationRepository, - PostgresConversationShareRepository, PostgresCreditsRepository, - PostgresEmailVerificationChallengeRepository, PostgresFileRepository, PostgresModelRepository, + PostgresAppConfigRepository, PostgresBiMetricsRepository, PostgresCreditsRepository, + PostgresEmailVerificationChallengeRepository, PostgresModelRepository, PostgresNearNonceRepository, PostgresOAuthRepository, PostgresPaymentWebhookRepository, PostgresSessionRepository, PostgresStripeCustomerRepository, PostgresSubscriptionRepository, PostgresSystemConfigsRepository, PostgresUserRepository, PostgresUserSettingsRepository, @@ -31,9 +30,6 @@ pub struct Database { user_repository: Arc, session_repository: Arc, oauth_repository: Arc, - conversation_repository: Arc, - conversation_share_repository: Arc, - file_repository: Arc, user_settings_repository: Arc, system_configs_repository: Arc, app_config_repository: Arc, @@ -58,10 +54,6 @@ impl Database { let user_repository = Arc::new(PostgresUserRepository::new(pool.clone())); let session_repository = Arc::new(PostgresSessionRepository::new(pool.clone())); let oauth_repository = Arc::new(PostgresOAuthRepository::new(pool.clone())); - let conversation_repository = Arc::new(PostgresConversationRepository::new(pool.clone())); - let conversation_share_repository = - Arc::new(PostgresConversationShareRepository::new(pool.clone())); - let file_repository = Arc::new(PostgresFileRepository::new(pool.clone())); let user_settings_repository = Arc::new(PostgresUserSettingsRepository::new(pool.clone())); let system_configs_repository = Arc::new(PostgresSystemConfigsRepository::new(pool.clone())); @@ -88,9 +80,6 @@ impl Database { user_repository, session_repository, oauth_repository, - conversation_repository, - conversation_share_repository, - file_repository, user_settings_repository, system_configs_repository, app_config_repository, @@ -247,21 +236,6 @@ impl Database { self.oauth_repository.clone() } - /// Get the conversation repository - pub fn conversation_repository(&self) -> Arc { - self.conversation_repository.clone() - } - - /// Get the conversation share repository - pub fn conversation_share_repository(&self) -> Arc { - self.conversation_share_repository.clone() - } - - /// Get the file repository - pub fn file_repository(&self) -> Arc { - self.file_repository.clone() - } - /// Get the user settings repository pub fn user_settings_repository(&self) -> Arc { self.user_settings_repository.clone() diff --git a/crates/database/src/repositories/conversation_repository.rs b/crates/database/src/repositories/conversation_repository.rs deleted file mode 100644 index 53d4c0b0..00000000 --- a/crates/database/src/repositories/conversation_repository.rs +++ /dev/null @@ -1,172 +0,0 @@ -use crate::pool::DbPool; -use async_trait::async_trait; -use services::conversation::ports::{ConversationError, ConversationRepository}; -use services::UserId; - -pub struct PostgresConversationRepository { - pool: DbPool, -} - -impl PostgresConversationRepository { - pub fn new(pool: DbPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl ConversationRepository for PostgresConversationRepository { - async fn upsert_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result<(), ConversationError> { - tracing::debug!( - "Repository: Upserting conversation - conversation_id={}, user_id={}", - conversation_id, - user_id - ); - - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - client - .execute( - "INSERT INTO conversations (id, user_id) - VALUES ($1, $2) - ON CONFLICT (id) - DO UPDATE SET updated_at = NOW()", - &[&conversation_id, &user_id.0], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - tracing::debug!( - "Repository: Conversation upserted - conversation_id={}, user_id={}", - conversation_id, - user_id - ); - - Ok(()) - } - - async fn list_conversations(&self, user_id: UserId) -> Result, ConversationError> { - tracing::debug!("Repository: Listing conversations for user_id={}", user_id); - - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let rows = client - .query( - "SELECT id FROM conversations - WHERE user_id = $1 - ORDER BY updated_at DESC", - &[&user_id.0], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let conversation_ids: Vec = rows.iter().map(|row| row.get(0)).collect(); - - tracing::debug!( - "Repository: Found {} conversation(s) for user_id={}", - conversation_ids.len(), - user_id - ); - - Ok(conversation_ids) - } - - async fn access_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result<(), ConversationError> { - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let row = client - .query_opt( - "SELECT id FROM conversations - WHERE id = $1 AND user_id = $2", - &[&conversation_id, &user_id.0], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - match row { - Some(_) => Ok(()), - None => Err(ConversationError::NotFound), - } - } - - async fn get_conversation_owner( - &self, - conversation_id: &str, - ) -> Result, ConversationError> { - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let row = client - .query_opt( - "SELECT user_id FROM conversations WHERE id = $1", - &[&conversation_id], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - Ok(row.map(|r| UserId(r.get("user_id")))) - } - - async fn delete_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result<(), ConversationError> { - let mut client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let tx = client - .transaction() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - tx.execute( - "DELETE FROM conversation_shares WHERE conversation_id = $1", - &[&conversation_id], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let result = tx - .execute( - "DELETE FROM conversations WHERE id = $1 AND user_id = $2", - &[&conversation_id, &user_id.0], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - if result == 0 { - Err(ConversationError::NotFound) - } else { - tx.commit() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - Ok(()) - } - } -} diff --git a/crates/database/src/repositories/conversation_share_repository.rs b/crates/database/src/repositories/conversation_share_repository.rs deleted file mode 100644 index 4350a4d5..00000000 --- a/crates/database/src/repositories/conversation_share_repository.rs +++ /dev/null @@ -1,914 +0,0 @@ -use crate::pool::DbPool; -use async_trait::async_trait; -use services::conversation::ports::{ - ConversationError, ConversationShare, ConversationShareRepository, NewConversationShare, - ShareGroup, SharePermission, ShareRecipient, ShareRecipientKind, ShareType, -}; -use services::UserId; -use std::collections::HashMap; -use uuid::Uuid; - -pub struct PostgresConversationShareRepository { - pool: DbPool, -} - -impl PostgresConversationShareRepository { - pub fn new(pool: DbPool) -> Self { - Self { pool } - } - - fn map_permission(value: &str) -> Result { - match value { - "read" => Ok(SharePermission::Read), - "write" => Ok(SharePermission::Write), - _ => Err(ConversationError::DatabaseError(format!( - "Unknown share permission: {value}" - ))), - } - } - - fn map_share_type(value: &str) -> Result { - match value { - "direct" => Ok(ShareType::Direct), - "group" => Ok(ShareType::Group), - "organization" => Ok(ShareType::Organization), - "public" => Ok(ShareType::Public), - _ => Err(ConversationError::DatabaseError(format!( - "Unknown share type: {value}" - ))), - } - } - - fn map_recipient_kind(value: &str) -> Result { - match value { - "email" => Ok(ShareRecipientKind::Email), - "near" => Ok(ShareRecipientKind::NearAccount), - _ => Err(ConversationError::DatabaseError(format!( - "Unknown share recipient kind: {value}" - ))), - } - } - - fn map_share_row(row: &tokio_postgres::Row) -> Result { - let recipient_kind: Option = row.get("recipient_type"); - let recipient_value: Option = row.get("recipient_value"); - let recipient = match (recipient_kind, recipient_value) { - (Some(kind), Some(value)) => Some(ShareRecipient { - kind: Self::map_recipient_kind(&kind)?, - value, - }), - _ => None, - }; - - Ok(ConversationShare { - id: row.get("id"), - conversation_id: row.get("conversation_id"), - owner_user_id: row.get("owner_user_id"), - share_type: Self::map_share_type(row.get("share_type"))?, - permission: Self::map_permission(row.get("permission"))?, - recipient, - group_id: row.get("group_id"), - org_email_pattern: row.get("org_email_pattern"), - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - }) - } - - async fn load_group_members( - &self, - group_ids: &[Uuid], - ) -> Result>, ConversationError> { - if group_ids.is_empty() { - return Ok(HashMap::new()); - } - - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let rows = client - .query( - "SELECT group_id, member_type, member_value - FROM conversation_share_group_members - WHERE group_id = ANY($1)", - &[&group_ids], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let mut members: HashMap> = HashMap::new(); - - for row in rows { - let group_id: Uuid = row.get("group_id"); - let kind = Self::map_recipient_kind(row.get("member_type"))?; - let value: String = row.get("member_value"); - members - .entry(group_id) - .or_default() - .push(ShareRecipient { kind, value }); - } - - Ok(members) - } - - fn to_share_group(row: &tokio_postgres::Row, members: Vec) -> ShareGroup { - ShareGroup { - id: row.get("id"), - owner_user_id: row.get("owner_user_id"), - name: row.get("name"), - members, - created_at: row.get("created_at"), - updated_at: row.get("updated_at"), - } - } -} - -#[async_trait] -impl ConversationShareRepository for PostgresConversationShareRepository { - async fn create_group( - &self, - owner_user_id: UserId, - name: &str, - members: &[ShareRecipient], - ) -> Result { - let mut client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let transaction = client - .transaction() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let row = transaction - .query_one( - "INSERT INTO conversation_share_groups (owner_user_id, name) - VALUES ($1, $2) - RETURNING id, owner_user_id, name, created_at, updated_at", - &[&owner_user_id.0, &name], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let group_id: Uuid = row.get("id"); - - for member in members { - transaction - .execute( - "INSERT INTO conversation_share_group_members (group_id, member_type, member_value) - VALUES ($1, $2, $3) - ON CONFLICT (group_id, member_type, member_value) DO NOTHING", - &[&group_id, &member.kind.as_str(), &member.value], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - } - - transaction - .commit() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - Ok(Self::to_share_group(&row, members.to_vec())) - } - - async fn list_groups( - &self, - owner_user_id: UserId, - ) -> Result, ConversationError> { - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let rows = client - .query( - "SELECT id, owner_user_id, name, created_at, updated_at - FROM conversation_share_groups - WHERE owner_user_id = $1 - ORDER BY name", - &[&owner_user_id.0], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let group_ids: Vec = rows.iter().map(|row| row.get("id")).collect(); - let members = self.load_group_members(&group_ids).await?; - - let groups = rows - .iter() - .map(|row| { - let id: Uuid = row.get("id"); - Self::to_share_group(row, members.get(&id).cloned().unwrap_or_default()) - }) - .collect(); - - Ok(groups) - } - - async fn list_groups_for_member( - &self, - member_identifiers: &[ShareRecipient], - ) -> Result, ConversationError> { - if member_identifiers.is_empty() { - return Ok(Vec::new()); - } - - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - // Use UNNEST to create pairs of (type, value) from arrays - // This is safer than dynamic SQL construction and maintains correct pair matching - // UNNEST with multiple arrays creates rows where elements at the same position are paired - let member_types: Vec = member_identifiers - .iter() - .map(|m| m.kind.as_str().to_string()) - .collect(); - let member_values_lower: Vec = member_identifiers - .iter() - .map(|m| m.value.to_lowercase()) - .collect(); - - // Use parameterized query with UNNEST to safely match (type, value) pairs - // This avoids dynamic SQL construction while maintaining correct pairing semantics - let rows = client - .query( - "SELECT DISTINCT g.id, g.owner_user_id, g.name, g.created_at, g.updated_at - FROM conversation_share_groups g - JOIN conversation_share_group_members m ON g.id = m.group_id - JOIN UNNEST($1::text[], $2::text[]) AS search(member_type, member_value) - ON m.member_type = search.member_type - AND LOWER(m.member_value) = search.member_value - ORDER BY g.name", - &[&member_types, &member_values_lower], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let group_ids: Vec = rows.iter().map(|row| row.get("id")).collect(); - let members = self.load_group_members(&group_ids).await?; - - let groups = rows - .iter() - .map(|row| { - let id: Uuid = row.get("id"); - Self::to_share_group(row, members.get(&id).cloned().unwrap_or_default()) - }) - .collect(); - - Ok(groups) - } - - async fn get_group( - &self, - owner_user_id: UserId, - group_id: Uuid, - ) -> Result, ConversationError> { - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let row = client - .query_opt( - "SELECT id, owner_user_id, name, created_at, updated_at - FROM conversation_share_groups - WHERE owner_user_id = $1 AND id = $2", - &[&owner_user_id.0, &group_id], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let Some(row) = row else { - return Ok(None); - }; - - let members = self.load_group_members(&[group_id]).await?; - let group = Self::to_share_group(&row, members.get(&group_id).cloned().unwrap_or_default()); - Ok(Some(group)) - } - - async fn update_group( - &self, - owner_user_id: UserId, - group_id: Uuid, - name: Option<&str>, - members: Option<&[ShareRecipient]>, - ) -> Result { - let mut client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let transaction = client - .transaction() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let row = transaction - .query_opt( - "UPDATE conversation_share_groups - SET name = COALESCE($1, name), updated_at = NOW() - WHERE owner_user_id = $2 AND id = $3 - RETURNING id, owner_user_id, name, created_at, updated_at", - &[&name, &owner_user_id.0, &group_id], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let Some(row) = row else { - return Err(ConversationError::NotFound); - }; - - if let Some(members) = members { - transaction - .execute( - "DELETE FROM conversation_share_group_members WHERE group_id = $1", - &[&group_id], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - for member in members { - transaction - .execute( - "INSERT INTO conversation_share_group_members (group_id, member_type, member_value) - VALUES ($1, $2, $3) - ON CONFLICT (group_id, member_type, member_value) DO NOTHING", - &[&group_id, &member.kind.as_str(), &member.value], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - } - } - - transaction - .commit() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let members = if let Some(members) = members { - members.to_vec() - } else { - let members_map = self.load_group_members(&[group_id]).await?; - members_map.get(&group_id).cloned().unwrap_or_default() - }; - - Ok(Self::to_share_group(&row, members)) - } - - async fn delete_group( - &self, - owner_user_id: UserId, - group_id: Uuid, - ) -> Result<(), ConversationError> { - let mut client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let tx = client - .transaction() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let group_exists = tx - .query_opt( - "SELECT 1 FROM conversation_share_groups WHERE owner_user_id = $1 AND id = $2 FOR UPDATE", - &[&owner_user_id.0, &group_id], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))? - .is_some(); - if !group_exists { - return Err(ConversationError::NotFound); - } - - tx.execute( - "DELETE FROM conversation_shares WHERE group_id = $1", - &[&group_id], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - tx.execute( - "DELETE FROM conversation_share_group_members WHERE group_id = $1", - &[&group_id], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - tx.execute( - "DELETE FROM conversation_share_groups WHERE owner_user_id = $1 AND id = $2", - &[&owner_user_id.0, &group_id], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - tx.commit() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - Ok(()) - } - - async fn create_share( - &self, - share: NewConversationShare, - ) -> Result { - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - // Use ON CONFLICT to update existing shares based on share type - let query = match share.share_type { - ShareType::Direct => { - "INSERT INTO conversation_shares ( - conversation_id, - owner_user_id, - share_type, - permission, - recipient_type, - recipient_value, - group_id, - org_email_pattern - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (conversation_id, recipient_type, recipient_value) - WHERE share_type = 'direct' - DO UPDATE SET - permission = EXCLUDED.permission, - updated_at = NOW() - RETURNING id, conversation_id, owner_user_id, share_type, permission, - recipient_type, recipient_value, group_id, org_email_pattern, - created_at, updated_at" - } - ShareType::Group => { - "INSERT INTO conversation_shares ( - conversation_id, - owner_user_id, - share_type, - permission, - recipient_type, - recipient_value, - group_id, - org_email_pattern - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (conversation_id, group_id) - WHERE share_type = 'group' - DO UPDATE SET - permission = EXCLUDED.permission, - updated_at = NOW() - RETURNING id, conversation_id, owner_user_id, share_type, permission, - recipient_type, recipient_value, group_id, org_email_pattern, - created_at, updated_at" - } - ShareType::Organization => { - "INSERT INTO conversation_shares ( - conversation_id, - owner_user_id, - share_type, - permission, - recipient_type, - recipient_value, - group_id, - org_email_pattern - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (conversation_id, org_email_pattern) - WHERE share_type = 'organization' - DO UPDATE SET - permission = EXCLUDED.permission, - updated_at = NOW() - RETURNING id, conversation_id, owner_user_id, share_type, permission, - recipient_type, recipient_value, group_id, org_email_pattern, - created_at, updated_at" - } - ShareType::Public => { - "INSERT INTO conversation_shares ( - conversation_id, - owner_user_id, - share_type, - permission, - recipient_type, - recipient_value, - group_id, - org_email_pattern - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (conversation_id) - WHERE share_type = 'public' - DO UPDATE SET - permission = EXCLUDED.permission, - updated_at = NOW() - RETURNING id, conversation_id, owner_user_id, share_type, permission, - recipient_type, recipient_value, group_id, org_email_pattern, - created_at, updated_at" - } - }; - - let row = client - .query_one( - query, - &[ - &share.conversation_id, - &share.owner_user_id.0, - &share.share_type.as_str(), - &share.permission.as_str(), - &share - .recipient - .as_ref() - .map(|recipient| recipient.kind.as_str()), - &share - .recipient - .as_ref() - .map(|recipient| recipient.value.as_str()), - &share.group_id, - &share.org_email_pattern, - ], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - Self::map_share_row(&row) - } - - /// Create multiple shares atomically (all succeed or all fail). - /// If a share already exists, updates the permission instead of failing. - async fn create_shares_batch( - &self, - shares: Vec, - ) -> Result, ConversationError> { - if shares.is_empty() { - return Ok(vec![]); - } - - let mut client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let transaction = client - .transaction() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let mut results = Vec::with_capacity(shares.len()); - - for share in shares { - // Use ON CONFLICT to update existing shares based on share type - let query = match share.share_type { - ShareType::Direct => { - "INSERT INTO conversation_shares ( - conversation_id, - owner_user_id, - share_type, - permission, - recipient_type, - recipient_value, - group_id, - org_email_pattern - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (conversation_id, recipient_type, recipient_value) - WHERE share_type = 'direct' - DO UPDATE SET - permission = EXCLUDED.permission, - updated_at = NOW() - RETURNING id, conversation_id, owner_user_id, share_type, permission, - recipient_type, recipient_value, group_id, org_email_pattern, - created_at, updated_at" - } - ShareType::Group => { - "INSERT INTO conversation_shares ( - conversation_id, - owner_user_id, - share_type, - permission, - recipient_type, - recipient_value, - group_id, - org_email_pattern - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (conversation_id, group_id) - WHERE share_type = 'group' - DO UPDATE SET - permission = EXCLUDED.permission, - updated_at = NOW() - RETURNING id, conversation_id, owner_user_id, share_type, permission, - recipient_type, recipient_value, group_id, org_email_pattern, - created_at, updated_at" - } - ShareType::Organization => { - "INSERT INTO conversation_shares ( - conversation_id, - owner_user_id, - share_type, - permission, - recipient_type, - recipient_value, - group_id, - org_email_pattern - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (conversation_id, org_email_pattern) - WHERE share_type = 'organization' - DO UPDATE SET - permission = EXCLUDED.permission, - updated_at = NOW() - RETURNING id, conversation_id, owner_user_id, share_type, permission, - recipient_type, recipient_value, group_id, org_email_pattern, - created_at, updated_at" - } - ShareType::Public => { - "INSERT INTO conversation_shares ( - conversation_id, - owner_user_id, - share_type, - permission, - recipient_type, - recipient_value, - group_id, - org_email_pattern - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - ON CONFLICT (conversation_id) - WHERE share_type = 'public' - DO UPDATE SET - permission = EXCLUDED.permission, - updated_at = NOW() - RETURNING id, conversation_id, owner_user_id, share_type, permission, - recipient_type, recipient_value, group_id, org_email_pattern, - created_at, updated_at" - } - }; - - let row = transaction - .query_one( - query, - &[ - &share.conversation_id, - &share.owner_user_id.0, - &share.share_type.as_str(), - &share.permission.as_str(), - &share - .recipient - .as_ref() - .map(|recipient| recipient.kind.as_str()), - &share - .recipient - .as_ref() - .map(|recipient| recipient.value.as_str()), - &share.group_id, - &share.org_email_pattern, - ], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - results.push(Self::map_share_row(&row)?); - } - - transaction - .commit() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - Ok(results) - } - - async fn list_shares( - &self, - owner_user_id: UserId, - conversation_id: &str, - ) -> Result, ConversationError> { - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let rows = client - .query( - "SELECT id, conversation_id, owner_user_id, share_type, permission, - recipient_type, recipient_value, group_id, org_email_pattern, - created_at, updated_at - FROM conversation_shares - WHERE owner_user_id = $1 AND conversation_id = $2 - ORDER BY created_at", - &[&owner_user_id.0, &conversation_id], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - rows.iter() - .map(Self::map_share_row) - .collect::, _>>() - } - - async fn delete_share( - &self, - owner_user_id: UserId, - conversation_id: &str, - share_id: Uuid, - ) -> Result<(), ConversationError> { - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let deleted = client - .execute( - "DELETE FROM conversation_shares - WHERE owner_user_id = $1 AND conversation_id = $2 AND id = $3", - &[&owner_user_id.0, &conversation_id, &share_id], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - if deleted == 0 { - return Err(ConversationError::NotFound); - } - - Ok(()) - } - - async fn get_share_permission_for_user( - &self, - conversation_id: &str, - email: &str, - near_accounts: &[String], - ) -> Result, ConversationError> { - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let row = client - .query_opt( - "SELECT permission FROM ( - SELECT permission - FROM conversation_shares - WHERE conversation_id = $1 - AND share_type = 'direct' - AND ( - (recipient_type = 'email' AND recipient_value = $2) - OR - (recipient_type = 'near' AND recipient_value = ANY($3)) - ) - UNION ALL - SELECT cs.permission - FROM conversation_shares cs - JOIN conversation_share_group_members cgm - ON cs.group_id = cgm.group_id - WHERE cs.conversation_id = $1 - AND cs.share_type = 'group' - AND ( - (cgm.member_type = 'email' AND cgm.member_value = $2) - OR - (cgm.member_type = 'near' AND cgm.member_value = ANY($3)) - ) - UNION ALL - SELECT permission - FROM conversation_shares - WHERE conversation_id = $1 - AND share_type = 'organization' - AND $2 ILIKE org_email_pattern - ) perms - ORDER BY CASE WHEN permission = 'write' THEN 0 ELSE 1 END - LIMIT 1", - &[&conversation_id, &email, &near_accounts], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - match row { - Some(row) => { - let permission: String = row.get("permission"); - let permission = Self::map_permission(&permission)?; - Ok(Some(permission)) - } - None => Ok(None), - } - } - - async fn get_public_share_by_conversation_id( - &self, - conversation_id: &str, - ) -> Result, ConversationError> { - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let row = client - .query_opt( - "SELECT id, conversation_id, owner_user_id, share_type, permission, - recipient_type, recipient_value, group_id, org_email_pattern, - created_at, updated_at - FROM conversation_shares - WHERE share_type = 'public' AND conversation_id = $1", - &[&conversation_id], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - match row { - Some(row) => Ok(Some(Self::map_share_row(&row)?)), - None => Ok(None), - } - } - - async fn list_conversations_shared_with_user( - &self, - user_id: UserId, - email: &str, - near_accounts: &[String], - ) -> Result, ConversationError> { - let client = self - .pool - .get() - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - // Query to find all conversations shared with the user via direct shares, - // group memberships, or organization patterns. We take the highest permission - // (write > read) for each conversation. Excludes conversations owned by the user. - let rows = client - .query( - "SELECT conversation_id, MAX(CASE WHEN permission = 'write' THEN 1 ELSE 0 END) as has_write - FROM ( - -- Direct shares by email or NEAR account (exclude own) - SELECT conversation_id, permission - FROM conversation_shares - WHERE share_type = 'direct' - AND owner_user_id != $3 - AND ( - (recipient_type = 'email' AND recipient_value = $1) - OR - (recipient_type = 'near' AND recipient_value = ANY($2)) - ) - UNION ALL - -- Group shares where user is a member (exclude own) - SELECT cs.conversation_id, cs.permission - FROM conversation_shares cs - JOIN conversation_share_group_members cgm - ON cs.group_id = cgm.group_id - WHERE cs.share_type = 'group' - AND cs.owner_user_id != $3 - AND ( - (cgm.member_type = 'email' AND cgm.member_value = $1) - OR - (cgm.member_type = 'near' AND cgm.member_value = ANY($2)) - ) - UNION ALL - -- Organization shares matching email pattern (exclude own) - SELECT conversation_id, permission - FROM conversation_shares - WHERE share_type = 'organization' - AND owner_user_id != $3 - AND $1 ILIKE org_email_pattern - ) shares - GROUP BY conversation_id - ORDER BY conversation_id", - &[&email, &near_accounts, &user_id.0], - ) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let result = rows - .iter() - .map(|row| { - let conversation_id: String = row.get("conversation_id"); - let has_write: i32 = row.get("has_write"); - let permission = if has_write == 1 { - SharePermission::Write - } else { - SharePermission::Read - }; - (conversation_id, permission) - }) - .collect(); - - Ok(result) - } -} diff --git a/crates/database/src/repositories/file_repository.rs b/crates/database/src/repositories/file_repository.rs deleted file mode 100644 index ab60eb70..00000000 --- a/crates/database/src/repositories/file_repository.rs +++ /dev/null @@ -1,232 +0,0 @@ -use crate::pool::DbPool; -use async_trait::async_trait; -use services::file::ports::{FileData, FileError, FileRepository}; -use services::UserId; -use tokio_postgres::Row; - -pub struct PostgresFileRepository { - pool: DbPool, -} - -impl PostgresFileRepository { - pub fn new(pool: DbPool) -> Self { - Self { pool } - } -} - -#[async_trait] -impl FileRepository for PostgresFileRepository { - async fn upsert_file(&self, file: &FileData, user_id: UserId) -> Result<(), FileError> { - tracing::debug!( - "Repository: Upserting file - file_id={}, user_id={}", - file.id, - user_id - ); - - let client = self - .pool - .get() - .await - .map_err(|e| FileError::DatabaseError(e.to_string()))?; - - client - .execute( - "INSERT INTO files (id, user_id, bytes, file_created_at, file_expires_at, filename, purpose) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (id) - DO UPDATE SET - user_id = EXCLUDED.user_id, - bytes = EXCLUDED.bytes, - file_created_at = EXCLUDED.file_created_at, - file_expires_at = EXCLUDED.file_expires_at, - filename = EXCLUDED.filename, - purpose = EXCLUDED.purpose, - updated_at = NOW()", - &[ - &file.id, - &user_id.0, - &file.bytes, - &file.created_at, - &file.expires_at, - &file.filename, - &file.purpose, - ], - ) - .await - .map_err(|e| FileError::DatabaseError(e.to_string()))?; - - tracing::debug!( - "Repository: File upserted - file_id={}, user_id={}", - file.id, - user_id - ); - - Ok(()) - } - - async fn get_file(&self, file_id: &str, user_id: UserId) -> Result { - tracing::debug!( - "Repository: Getting file - file_id={}, user_id={}", - file_id, - user_id - ); - - let client = self - .pool - .get() - .await - .map_err(|e| FileError::DatabaseError(e.to_string()))?; - - let row = client - .query_opt( - "SELECT id, bytes, file_created_at, file_expires_at, filename, purpose - FROM files - WHERE id = $1 AND user_id = $2", - &[&file_id, &user_id.0], - ) - .await - .map_err(|e| FileError::DatabaseError(e.to_string()))?; - - match row { - Some(r) => Ok(raw_to_file_data(&r)), - None => Err(FileError::NotFound), - } - } - - async fn list_files( - &self, - user_id: UserId, - after: Option, - limit: i64, - order: &str, - purpose: Option, - ) -> Result, FileError> { - tracing::debug!( - "Repository: Listing files with pagination for user_id={}, after={:?}, limit={}, order={}", - user_id, - after, - limit, - order - ); - - let client = self - .pool - .get() - .await - .map_err(|e| FileError::DatabaseError(e.to_string()))?; - - // Validate order parameter - let order_clause = match order { - "asc" => "ASC", - "desc" => "DESC", - _ => { - return Err(FileError::DatabaseError( - "Invalid order parameter".to_string(), - )) - } - }; - - // Build query with optional purpose and optional cursor (after) - let rows = if let Some(after_id) = after { - // With cursor - let op = if order == "asc" { ">" } else { "<" }; - let sql = format!( - "SELECT id, bytes, file_created_at, file_expires_at, filename, purpose - FROM files - WHERE user_id = $1 - AND ($2::text IS NULL OR purpose = $2) - AND file_created_at {} ( - SELECT file_created_at - FROM files - WHERE id = $3 - AND user_id = $1 - AND ($2::text IS NULL OR purpose = $2) - ) - ORDER BY file_created_at {} - LIMIT $4", - op, order_clause - ); - client - .query(&sql, &[&user_id.0, &purpose, &after_id, &limit]) - .await - } else { - // Without cursor - let sql = format!( - "SELECT id, bytes, file_created_at, file_expires_at, filename, purpose - FROM files - WHERE user_id = $1 - AND ($2::text IS NULL OR purpose = $2) - ORDER BY file_created_at {} - LIMIT $3", - order_clause - ); - client.query(&sql, &[&user_id.0, &purpose, &limit]).await - } - .map_err(|e| FileError::DatabaseError(e.to_string()))?; - - let files: Vec = rows.iter().map(raw_to_file_data).collect(); - - tracing::debug!( - "Repository: Found {} file(s) with pagination for user_id={}", - files.len(), - user_id - ); - - Ok(files) - } - - async fn access_file(&self, file_id: &str, user_id: UserId) -> Result<(), FileError> { - let client = self - .pool - .get() - .await - .map_err(|e| FileError::DatabaseError(e.to_string()))?; - - let row = client - .query_opt( - "SELECT id FROM files - WHERE id = $1 AND user_id = $2", - &[&file_id, &user_id.0], - ) - .await - .map_err(|e| FileError::DatabaseError(e.to_string()))?; - - match row { - Some(_) => Ok(()), - None => Err(FileError::NotFound), - } - } - - async fn delete_file(&self, file_id: &str, user_id: UserId) -> Result<(), FileError> { - let client = self - .pool - .get() - .await - .map_err(|e| FileError::DatabaseError(e.to_string()))?; - - let result = client - .execute( - "DELETE FROM files WHERE id = $1 AND user_id = $2", - &[&file_id, &user_id.0], - ) - .await - .map_err(|e| FileError::DatabaseError(e.to_string()))?; - - if result == 0 { - Err(FileError::NotFound) - } else { - Ok(()) - } - } -} - -fn raw_to_file_data(row: &Row) -> FileData { - FileData { - id: row.get("id"), - bytes: row.get("bytes"), - created_at: row.get("file_created_at"), - expires_at: row.get("file_expires_at"), - filename: row.get("filename"), - purpose: row.get("purpose"), - } -} diff --git a/crates/database/src/repositories/mod.rs b/crates/database/src/repositories/mod.rs index c241468e..44b31f0d 100644 --- a/crates/database/src/repositories/mod.rs +++ b/crates/database/src/repositories/mod.rs @@ -3,11 +3,8 @@ pub mod aml_report_repository; pub mod analytics_repository; pub mod app_config_repository; pub mod bi_metrics_repository; -pub mod conversation_repository; -pub mod conversation_share_repository; pub mod credits_repository; pub mod email_verification_challenge_repository; -pub mod file_repository; pub mod model_repository; pub mod near_nonce_repository; pub mod oauth_repository; @@ -25,11 +22,8 @@ pub use aml_report_repository::PostgresAmlReportRepository; pub use analytics_repository::PostgresAnalyticsRepository; pub use app_config_repository::PostgresAppConfigRepository; pub use bi_metrics_repository::PostgresBiMetricsRepository; -pub use conversation_repository::PostgresConversationRepository; -pub use conversation_share_repository::PostgresConversationShareRepository; pub use credits_repository::PostgresCreditsRepository; pub use email_verification_challenge_repository::PostgresEmailVerificationChallengeRepository; -pub use file_repository::PostgresFileRepository; pub use model_repository::PostgresModelRepository; pub use near_nonce_repository::PostgresNearNonceRepository; pub use oauth_repository::PostgresOAuthRepository; diff --git a/crates/services/src/conversation/mod.rs b/crates/services/src/conversation/mod.rs deleted file mode 100644 index 5df47e1b..00000000 --- a/crates/services/src/conversation/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod ports; -pub mod service; -pub mod share_service; diff --git a/crates/services/src/conversation/ports.rs b/crates/services/src/conversation/ports.rs deleted file mode 100644 index 856a8e37..00000000 --- a/crates/services/src/conversation/ports.rs +++ /dev/null @@ -1,370 +0,0 @@ -use async_trait::async_trait; -use chrono::{DateTime, Utc}; -use uuid::Uuid; - -#[cfg(feature = "utoipa")] -use utoipa::ToSchema; - -use crate::UserId; - -#[derive(Debug, thiserror::Error)] -pub enum ConversationError { - #[error("Database error: {0}")] - DatabaseError(String), - #[error("Conversation not found")] - NotFound, - #[error("OpenAI API error: {0}")] - ApiError(String), - #[error("Access denied")] - AccessDenied, - #[error("Internal error: {0}")] - InternalError(String), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] -#[serde(rename_all = "snake_case")] -pub enum SharePermission { - Read, - Write, -} - -impl SharePermission { - pub fn as_str(&self) -> &'static str { - match self { - SharePermission::Read => "read", - SharePermission::Write => "write", - } - } - - pub fn allows_write(&self) -> bool { - matches!(self, SharePermission::Write) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] -#[serde(rename_all = "snake_case")] -pub enum ShareRecipientKind { - Email, - NearAccount, -} - -impl ShareRecipientKind { - pub fn as_str(&self) -> &'static str { - match self { - ShareRecipientKind::Email => "email", - ShareRecipientKind::NearAccount => "near", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] -pub struct ShareRecipient { - pub kind: ShareRecipientKind, - pub value: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] -#[cfg_attr(feature = "utoipa", derive(ToSchema))] -#[serde(rename_all = "snake_case")] -pub enum ShareType { - Direct, - Group, - Organization, - Public, -} - -impl ShareType { - pub fn as_str(&self) -> &'static str { - match self { - ShareType::Direct => "direct", - ShareType::Group => "group", - ShareType::Organization => "organization", - ShareType::Public => "public", - } - } -} - -#[derive(Debug, Clone)] -pub struct ShareGroup { - pub id: Uuid, - pub owner_user_id: UserId, - pub name: String, - pub members: Vec, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone)] -pub struct ConversationShare { - pub id: Uuid, - pub conversation_id: String, - pub owner_user_id: UserId, - pub share_type: ShareType, - pub permission: SharePermission, - pub recipient: Option, - pub group_id: Option, - pub org_email_pattern: Option, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -#[derive(Debug, Clone)] -pub struct NewConversationShare { - pub conversation_id: String, - pub owner_user_id: UserId, - pub share_type: ShareType, - pub permission: SharePermission, - pub recipient: Option, - pub group_id: Option, - pub org_email_pattern: Option, -} - -#[async_trait] -pub trait ConversationRepository: Send + Sync { - /// Track a conversation ID for a user - async fn upsert_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result<(), ConversationError>; - - /// List all conversation IDs for a user - async fn list_conversations(&self, user_id: UserId) -> Result, ConversationError>; - - /// Check if a conversation exists for a user - async fn access_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result<(), ConversationError>; - - /// Get the owner of a conversation (returns None if conversation doesn't exist) - async fn get_conversation_owner( - &self, - conversation_id: &str, - ) -> Result, ConversationError>; - - /// Delete a conversation for a user - async fn delete_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result<(), ConversationError>; -} - -#[async_trait] -pub trait ConversationShareRepository: Send + Sync { - async fn create_group( - &self, - owner_user_id: UserId, - name: &str, - members: &[ShareRecipient], - ) -> Result; - - async fn list_groups( - &self, - owner_user_id: UserId, - ) -> Result, ConversationError>; - - /// List groups where the user is a member (by email or NEAR account) - async fn list_groups_for_member( - &self, - member_identifiers: &[ShareRecipient], - ) -> Result, ConversationError>; - - async fn get_group( - &self, - owner_user_id: UserId, - group_id: Uuid, - ) -> Result, ConversationError>; - - async fn update_group( - &self, - owner_user_id: UserId, - group_id: Uuid, - name: Option<&str>, - members: Option<&[ShareRecipient]>, - ) -> Result; - - async fn delete_group( - &self, - owner_user_id: UserId, - group_id: Uuid, - ) -> Result<(), ConversationError>; - - async fn create_share( - &self, - share: NewConversationShare, - ) -> Result; - - /// Create multiple shares atomically (all succeed or all fail) - async fn create_shares_batch( - &self, - shares: Vec, - ) -> Result, ConversationError>; - - async fn list_shares( - &self, - owner_user_id: UserId, - conversation_id: &str, - ) -> Result, ConversationError>; - - async fn delete_share( - &self, - owner_user_id: UserId, - conversation_id: &str, - share_id: Uuid, - ) -> Result<(), ConversationError>; - - async fn get_share_permission_for_user( - &self, - conversation_id: &str, - email: &str, - near_accounts: &[String], - ) -> Result, ConversationError>; - - /// Get the public share for a conversation by conversation ID (if one exists) - async fn get_public_share_by_conversation_id( - &self, - conversation_id: &str, - ) -> Result, ConversationError>; - - /// List all conversation IDs that have been shared with the user (excludes user's own conversations) - async fn list_conversations_shared_with_user( - &self, - user_id: UserId, - email: &str, - near_accounts: &[String], - ) -> Result, ConversationError>; -} - -#[async_trait] -pub trait ConversationService: Send + Sync { - /// Track a conversation ID for a user - async fn track_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result<(), ConversationError>; - - /// List all conversations for a user with details from OpenAI - async fn list_conversations( - &self, - user_id: UserId, - ) -> Result, ConversationError>; - - /// Get a conversation with details from OpenAI (checks user access first) - async fn get_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result; - - /// Ensure the user has access to a conversation using only the local database - async fn access_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result<(), ConversationError>; - - /// Get the owner of a conversation (returns None if conversation doesn't exist) - async fn get_conversation_owner( - &self, - conversation_id: &str, - ) -> Result, ConversationError>; - - /// Delete a conversation for a user - async fn delete_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result; -} - -#[derive(Debug, Clone)] -pub enum ShareTarget { - Direct(Vec), - Group(Uuid), - Organization(String), - Public, -} - -#[async_trait] -pub trait ConversationShareService: Send + Sync { - async fn ensure_access( - &self, - conversation_id: &str, - user_id: UserId, - required_permission: SharePermission, - ) -> Result<(), ConversationError>; - - /// Get public access for a conversation by ID (if it has a public share) - async fn get_public_access_by_conversation_id( - &self, - conversation_id: &str, - required_permission: SharePermission, - ) -> Result; - - async fn create_group( - &self, - owner_user_id: UserId, - name: &str, - members: Vec, - ) -> Result; - - async fn list_groups( - &self, - owner_user_id: UserId, - ) -> Result, ConversationError>; - - /// List all groups accessible to a user (owned + member of) - async fn list_accessible_groups( - &self, - owner_user_id: UserId, - member_identifiers: &[ShareRecipient], - ) -> Result, ConversationError>; - - async fn update_group( - &self, - owner_user_id: UserId, - group_id: Uuid, - name: Option, - members: Option>, - ) -> Result; - - async fn delete_group( - &self, - owner_user_id: UserId, - group_id: Uuid, - ) -> Result<(), ConversationError>; - - async fn create_share( - &self, - actor_user_id: UserId, - conversation_id: &str, - permission: SharePermission, - target: ShareTarget, - ) -> Result, ConversationError>; - - async fn list_shares( - &self, - owner_user_id: UserId, - conversation_id: &str, - ) -> Result, ConversationError>; - - async fn delete_share( - &self, - actor_user_id: UserId, - conversation_id: &str, - share_id: Uuid, - ) -> Result<(), ConversationError>; - - /// List all conversations that have been shared with the user - async fn list_shared_with_me( - &self, - user_id: UserId, - ) -> Result, ConversationError>; -} diff --git a/crates/services/src/conversation/service.rs b/crates/services/src/conversation/service.rs deleted file mode 100644 index 7b3df007..00000000 --- a/crates/services/src/conversation/service.rs +++ /dev/null @@ -1,420 +0,0 @@ -use async_trait::async_trait; -use bytes::Bytes; -use futures::TryStreamExt; -use http::Method; -use std::sync::Arc; - -use super::ports::{ConversationError, ConversationRepository, ConversationService}; -use crate::response::ports::OpenAIProxyService; -use crate::UserId; - -pub struct ConversationServiceImpl { - repository: Arc, - openai_proxy: Arc, -} - -impl ConversationServiceImpl { - pub fn new( - repository: Arc, - openai_proxy: Arc, - ) -> Self { - Self { - repository, - openai_proxy, - } - } -} - -#[async_trait] -impl ConversationService for ConversationServiceImpl { - async fn track_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result<(), ConversationError> { - tracing::info!( - "Tracking conversation: conversation_id={}, user_id={}", - conversation_id, - user_id - ); - - self.repository - .upsert_conversation(conversation_id, user_id) - .await?; - - tracing::info!( - "Conversation tracked successfully: conversation_id={}, user_id={}", - conversation_id, - user_id - ); - - Ok(()) - } - - async fn list_conversations( - &self, - user_id: UserId, - ) -> Result, ConversationError> { - tracing::info!("Listing conversations for user_id={}", user_id); - - // Get conversation IDs from database - let conversation_ids = self.repository.list_conversations(user_id).await?; - - tracing::info!( - "Retrieved {} conversation ID(s) from database for user_id={}", - conversation_ids.len(), - user_id - ); - - // Early return if no conversations - if conversation_ids.is_empty() { - return Ok(Vec::new()); - } - - // Fetch all conversations using batch API - let conversations = self.batch_fetch_conversations(&conversation_ids).await?; - - tracing::info!( - "Successfully fetched {} conversation(s) from OpenAI for user_id={}", - conversations.len(), - user_id - ); - - Ok(conversations) - } - - async fn get_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result { - tracing::info!( - "Getting conversation: conversation_id={}, user_id={}", - conversation_id, - user_id - ); - - // Check if user has access to this conversation - self.repository - .access_conversation(conversation_id, user_id) - .await?; - - tracing::debug!( - "User {} has access to conversation {}, fetching from OpenAI", - user_id, - conversation_id - ); - - // Fetch details from OpenAI - let conversation = self.fetch_conversation_from_openai(conversation_id).await?; - - Ok(conversation) - } - - async fn access_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result<(), ConversationError> { - self.repository - .access_conversation(conversation_id, user_id) - .await - } - - async fn get_conversation_owner( - &self, - conversation_id: &str, - ) -> Result, ConversationError> { - self.repository - .get_conversation_owner(conversation_id) - .await - } - - async fn delete_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result { - tracing::info!( - "Deleting conversation: conversation_id={}, user_id={}", - conversation_id, - user_id - ); - - self.access_conversation(conversation_id, user_id).await?; - - // First delete conversation from OpenAI - let deleted = self - .delete_conversation_from_openai(conversation_id) - .await?; - - // Then delete from database - self.repository - .delete_conversation(conversation_id, user_id) - .await?; - - tracing::info!( - "Conversation deleted successfully: conversation_id={}, user_id={}", - conversation_id, - user_id - ); - - Ok(deleted) - } -} - -impl ConversationServiceImpl { - /// Batch fetch multiple conversations from OpenAI API - /// Handles large lists by chunking into batches and making parallel requests - async fn batch_fetch_conversations( - &self, - conversation_ids: &[String], - ) -> Result, ConversationError> { - const BATCH_SIZE: usize = 1000; - - tracing::debug!( - "Batch fetching {} conversations from OpenAI", - conversation_ids.len() - ); - - // Split conversation IDs into chunks of 1000 - let chunks: Vec<&[String]> = conversation_ids.chunks(BATCH_SIZE).collect(); - - if chunks.is_empty() { - return Ok(Vec::new()); - } - - tracing::debug!( - "Splitting {} conversations into {} batch request(s) of max {} each", - conversation_ids.len(), - chunks.len(), - BATCH_SIZE - ); - - // Create futures for all batch requests - let futures: Vec<_> = chunks - .into_iter() - .enumerate() - .map(|(idx, chunk)| { - let openai_proxy = self.openai_proxy.clone(); - async move { - tracing::debug!( - "Batch request {}: fetching {} conversations", - idx + 1, - chunk.len() - ); - Self::make_batch_request(openai_proxy, chunk).await - } - }) - .collect(); - - // Execute all batch requests in parallel - let results = futures::future::join_all(futures).await; - - // Combine results from all batch requests - let mut all_conversations = Vec::new(); - let mut all_missing_ids = Vec::new(); - - for (idx, result) in results.into_iter().enumerate() { - match result { - Ok((conversations, missing_ids)) => { - all_conversations.extend(conversations); - all_missing_ids.extend(missing_ids); - } - Err(e) => { - tracing::error!("Batch request {} failed: {}", idx + 1, e); - return Err(e); - } - } - } - - // Log any missing conversations - if !all_missing_ids.is_empty() { - tracing::warn!( - "Failed to fetch {} conversation(s) from OpenAI: {:?}", - all_missing_ids.len(), - all_missing_ids - ); - } - - tracing::debug!( - "Successfully batch fetched {} conversations from OpenAI", - all_conversations.len() - ); - - Ok(all_conversations) - } - - /// Make a single batch request to OpenAI API - async fn make_batch_request( - openai_proxy: std::sync::Arc, - conversation_ids: &[String], - ) -> Result<(Vec, Vec), ConversationError> { - let path = "conversations/batch"; - - // Build request body - #[derive(serde::Serialize)] - struct BatchRequest { - ids: Vec, - } - - let request_body = BatchRequest { - ids: conversation_ids.to_vec(), - }; - - let body_bytes = serde_json::to_vec(&request_body).map_err(|e| { - ConversationError::ApiError(format!("Failed to serialize request: {}", e)) - })?; - - // Make batch request with Content-Type header - let mut headers = http::HeaderMap::new(); - headers.insert( - http::header::CONTENT_TYPE, - "application/json".parse().unwrap(), - ); - - let response = openai_proxy - .forward_request(Method::POST, path, headers, Some(Bytes::from(body_bytes))) - .await - .map_err(|e| ConversationError::ApiError(e.to_string()))?; - - if response.status != 200 { - tracing::error!( - "OpenAI batch API returned status {} for conversations batch", - response.status - ); - return Err(ConversationError::ApiError(format!( - "OpenAI batch API returned status {}", - response.status - ))); - } - - // Collect the response body - let body_bytes: Bytes = response - .body - .try_collect::>() - .await - .map_err(|e| ConversationError::ApiError(format!("Failed to read response: {}", e)))? - .into_iter() - .flatten() - .collect(); - - // Parse batch response - #[derive(serde::Deserialize)] - struct BatchResponse { - data: Vec, - missing_ids: Vec, - } - - let batch_response: BatchResponse = serde_json::from_slice(&body_bytes) - .map_err(|e| ConversationError::ApiError(format!("Failed to parse JSON: {}", e)))?; - - Ok((batch_response.data, batch_response.missing_ids)) - } - - /// Fetch conversation details from OpenAI API - async fn fetch_conversation_from_openai( - &self, - conversation_id: &str, - ) -> Result { - let path = format!("conversations/{}", conversation_id); - - tracing::debug!("Fetching conversation from OpenAI: {}", path); - - let response = self - .openai_proxy - .forward_request(Method::GET, &path, http::HeaderMap::new(), None) - .await - .map_err(|e| ConversationError::ApiError(e.to_string()))?; - - if response.status != 200 { - tracing::error!( - "OpenAI API returned status {} for conversation {}", - response.status, - conversation_id - ); - return Err(ConversationError::ApiError(format!( - "OpenAI API returned status {}", - response.status - ))); - } - - // Collect the response body - let body_bytes: Bytes = response - .body - .try_collect::>() - .await - .map_err(|e| ConversationError::ApiError(format!("Failed to read response: {}", e)))? - .into_iter() - .flatten() - .collect(); - - // Parse as JSON - let conversation: serde_json::Value = serde_json::from_slice(&body_bytes) - .map_err(|e| ConversationError::ApiError(format!("Failed to parse JSON: {}", e)))?; - - tracing::debug!( - "Successfully fetched conversation {} from OpenAI", - conversation_id - ); - - Ok(conversation) - } - - /// Delete conversation from OpenAI API and return the delete response - async fn delete_conversation_from_openai( - &self, - conversation_id: &str, - ) -> Result { - let path = format!("conversations/{}", conversation_id); - - tracing::debug!("Deleting conversation from OpenAI: {}", path); - - let response = self - .openai_proxy - .forward_request(Method::DELETE, &path, http::HeaderMap::new(), None) - .await - .map_err(|e| ConversationError::ApiError(e.to_string()))?; - - if response.status == 404 { - tracing::info!( - "Conversation {} already deleted from provider (404), treating as success", - conversation_id - ); - return Ok(serde_json::Value::Null); - } - - if response.status != 200 { - tracing::error!( - "OpenAI API returned status {} for conversation delete {}", - response.status, - conversation_id - ); - return Err(ConversationError::ApiError(format!( - "OpenAI API returned status {}", - response.status - ))); - } - - // Collect the response body - let body_bytes: Bytes = response - .body - .try_collect::>() - .await - .map_err(|e| ConversationError::ApiError(format!("Failed to read response: {}", e)))? - .into_iter() - .flatten() - .collect(); - - // Parse as JSON - let value: serde_json::Value = serde_json::from_slice(&body_bytes) - .map_err(|e| ConversationError::ApiError(format!("Failed to parse JSON: {}", e)))?; - - tracing::debug!( - "Successfully deleted conversation {} from OpenAI", - conversation_id - ); - - Ok(value) - } -} diff --git a/crates/services/src/conversation/share_service.rs b/crates/services/src/conversation/share_service.rs deleted file mode 100644 index 11219d0c..00000000 --- a/crates/services/src/conversation/share_service.rs +++ /dev/null @@ -1,2517 +0,0 @@ -use async_trait::async_trait; -use std::sync::Arc; - -use super::ports::{ - ConversationError, ConversationRepository, ConversationShare, ConversationShareRepository, - ConversationShareService, NewConversationShare, ShareGroup, SharePermission, ShareRecipient, - ShareRecipientKind, ShareTarget, ShareType, -}; -use crate::user::ports::{OAuthProvider, UserRepository}; -use crate::UserId; - -pub struct ConversationShareServiceImpl { - conversation_repository: Arc, - share_repository: Arc, - user_repository: Arc, -} - -impl ConversationShareServiceImpl { - pub fn new( - conversation_repository: Arc, - share_repository: Arc, - user_repository: Arc, - ) -> Self { - Self { - conversation_repository, - share_repository, - user_repository, - } - } - - async fn get_conversation_owner_and_ensure_write_access( - &self, - conversation_id: &str, - actor_user_id: UserId, - ) -> Result { - let conversation_owner_user_id = self - .conversation_repository - .get_conversation_owner(conversation_id) - .await? - .ok_or(ConversationError::NotFound)?; - - // Allow owners OR users with write permission to manage shares - self.ensure_access(conversation_id, actor_user_id, SharePermission::Write) - .await?; - - Ok(conversation_owner_user_id) - } - - fn normalize_recipient(recipient: ShareRecipient) -> ShareRecipient { - match recipient.kind { - ShareRecipientKind::Email => ShareRecipient { - kind: recipient.kind, - value: recipient.value.trim().to_lowercase(), - }, - ShareRecipientKind::NearAccount => ShareRecipient { - kind: recipient.kind, - value: recipient.value.trim().to_string(), - }, - } - } - - fn normalize_org_pattern(pattern: String) -> String { - let trimmed = pattern.trim().to_string(); - // If pattern contains wildcards (% or _), keep as-is - if trimmed.contains('%') || trimmed.contains('_') { - trimmed - } else if trimmed.starts_with('@') { - // @company.com -> %@company.com - format!("%{trimmed}") - } else if trimmed.contains('@') { - // user@company.com -> keep as-is (specific email match) - trimmed - } else { - // company.com -> %@company.com - format!("%@{trimmed}") - } - } - - fn has_required_permission( - share_permission: SharePermission, - required_permission: SharePermission, - ) -> bool { - match required_permission { - SharePermission::Read => true, - SharePermission::Write => share_permission.allows_write(), - } - } -} - -#[async_trait] -impl ConversationShareService for ConversationShareServiceImpl { - async fn ensure_access( - &self, - conversation_id: &str, - user_id: UserId, - required_permission: SharePermission, - ) -> Result<(), ConversationError> { - match self - .conversation_repository - .access_conversation(conversation_id, user_id) - .await - { - Ok(()) => return Ok(()), - Err(ConversationError::NotFound) | Err(ConversationError::AccessDenied) => {} - Err(error) => return Err(error), - } - - let user = self - .user_repository - .get_user(user_id) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))? - .ok_or(ConversationError::AccessDenied)?; - - let linked_accounts = self - .user_repository - .get_linked_accounts(user_id) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let near_accounts: Vec = linked_accounts - .into_iter() - .filter(|account| account.provider == OAuthProvider::Near) - .map(|account| account.provider_user_id) - .collect(); - - let email = user.email.to_lowercase(); - - let permission = self - .share_repository - .get_share_permission_for_user(conversation_id, &email, &near_accounts) - .await?; - - match permission { - Some(permission) if Self::has_required_permission(permission, required_permission) => { - Ok(()) - } - _ => Err(ConversationError::AccessDenied), - } - } - - async fn get_public_access_by_conversation_id( - &self, - conversation_id: &str, - required_permission: SharePermission, - ) -> Result { - let share = self - .share_repository - .get_public_share_by_conversation_id(conversation_id) - .await? - .ok_or(ConversationError::NotFound)?; - - if !Self::has_required_permission(share.permission, required_permission) { - return Err(ConversationError::AccessDenied); - } - - Ok(share) - } - - async fn create_group( - &self, - owner_user_id: UserId, - name: &str, - members: Vec, - ) -> Result { - let members = members - .into_iter() - .map(Self::normalize_recipient) - .collect::>(); - - self.share_repository - .create_group(owner_user_id, name, &members) - .await - } - - async fn list_groups( - &self, - owner_user_id: UserId, - ) -> Result, ConversationError> { - self.share_repository.list_groups(owner_user_id).await - } - - async fn list_accessible_groups( - &self, - owner_user_id: UserId, - member_identifiers: &[ShareRecipient], - ) -> Result, ConversationError> { - // Get groups owned by the user - let owned_groups = self.share_repository.list_groups(owner_user_id).await?; - - // Get groups where the user is a member - let member_groups = self - .share_repository - .list_groups_for_member(member_identifiers) - .await?; - - // Combine and deduplicate (owned groups take precedence) - let owned_ids: std::collections::HashSet<_> = owned_groups.iter().map(|g| g.id).collect(); - - let mut all_groups = owned_groups; - for group in member_groups { - if !owned_ids.contains(&group.id) { - all_groups.push(group); - } - } - - // Sort by name - all_groups.sort_by(|a, b| a.name.cmp(&b.name)); - - Ok(all_groups) - } - - async fn update_group( - &self, - owner_user_id: UserId, - group_id: uuid::Uuid, - name: Option, - members: Option>, - ) -> Result { - let members = members.map(|members| { - members - .into_iter() - .map(Self::normalize_recipient) - .collect::>() - }); - - self.share_repository - .update_group(owner_user_id, group_id, name.as_deref(), members.as_deref()) - .await - } - - async fn delete_group( - &self, - owner_user_id: UserId, - group_id: uuid::Uuid, - ) -> Result<(), ConversationError> { - self.share_repository - .delete_group(owner_user_id, group_id) - .await - } - - async fn create_share( - &self, - actor_user_id: UserId, - conversation_id: &str, - permission: SharePermission, - target: ShareTarget, - ) -> Result, ConversationError> { - // Verify conversation exists, resolve owner, and enforce write access for actor - let conversation_owner_user_id = self - .get_conversation_owner_and_ensure_write_access(conversation_id, actor_user_id) - .await?; - - let mut shares = Vec::new(); - - match target { - ShareTarget::Direct(recipients) => { - // Use batch creation for atomicity - all shares succeed or all fail - let share_requests: Vec = recipients - .into_iter() - .map(Self::normalize_recipient) - .map(|recipient| NewConversationShare { - conversation_id: conversation_id.to_string(), - owner_user_id: conversation_owner_user_id, - share_type: ShareType::Direct, - permission, - recipient: Some(recipient), - group_id: None, - org_email_pattern: None, - }) - .collect(); - - shares = self - .share_repository - .create_shares_batch(share_requests) - .await?; - } - ShareTarget::Group(group_id) => { - let group = self - .share_repository - .get_group(actor_user_id, group_id) - .await?; - - if group.is_none() { - return Err(ConversationError::AccessDenied); - } - - let share = self - .share_repository - .create_share(NewConversationShare { - conversation_id: conversation_id.to_string(), - owner_user_id: conversation_owner_user_id, - share_type: ShareType::Group, - permission, - recipient: None, - group_id: Some(group_id), - org_email_pattern: None, - }) - .await?; - - shares.push(share); - } - ShareTarget::Organization(pattern) => { - let normalized = Self::normalize_org_pattern(pattern); - let share = self - .share_repository - .create_share(NewConversationShare { - conversation_id: conversation_id.to_string(), - owner_user_id: conversation_owner_user_id, - share_type: ShareType::Organization, - permission, - recipient: None, - group_id: None, - org_email_pattern: Some(normalized), - }) - .await?; - - shares.push(share); - } - ShareTarget::Public => { - let share = self - .share_repository - .create_share(NewConversationShare { - conversation_id: conversation_id.to_string(), - owner_user_id: conversation_owner_user_id, - share_type: ShareType::Public, - permission, - recipient: None, - group_id: None, - org_email_pattern: None, - }) - .await?; - - shares.push(share); - } - } - - Ok(shares) - } - - async fn list_shares( - &self, - owner_user_id: UserId, - conversation_id: &str, - ) -> Result, ConversationError> { - self.share_repository - .list_shares(owner_user_id, conversation_id) - .await - } - - async fn delete_share( - &self, - actor_user_id: UserId, - conversation_id: &str, - share_id: uuid::Uuid, - ) -> Result<(), ConversationError> { - // Verify conversation exists, resolve owner, and enforce write access for actor - let conversation_owner_user_id = self - .get_conversation_owner_and_ensure_write_access(conversation_id, actor_user_id) - .await?; - - self.share_repository - .delete_share(conversation_owner_user_id, conversation_id, share_id) - .await - } - - async fn list_shared_with_me( - &self, - user_id: UserId, - ) -> Result, ConversationError> { - let user = self - .user_repository - .get_user(user_id) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))? - .ok_or(ConversationError::AccessDenied)?; - - let linked_accounts = self - .user_repository - .get_linked_accounts(user_id) - .await - .map_err(|e| ConversationError::DatabaseError(e.to_string()))?; - - let near_accounts: Vec = linked_accounts - .into_iter() - .filter(|account| account.provider == OAuthProvider::Near) - .map(|account| account.provider_user_id) - .collect(); - - let email = user.email.to_lowercase(); - - self.share_repository - .list_conversations_shared_with_user(user_id, &email, &near_accounts) - .await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::conversation::ports::{ - ConversationShareRepository, ShareGroup, SharePermission, ShareRecipient, - ShareRecipientKind, ShareTarget, ShareType, - }; - use crate::user::ports::{ - LinkedOAuthAccount, OAuthProvider, User, UserRepository, UserService, - }; - use async_trait::async_trait; - use chrono::Utc; - use std::collections::{HashMap, HashSet}; - use std::sync::Mutex; - use uuid::Uuid; - - #[derive(Default)] - struct InMemoryConversationRepo { - owners: Mutex>, - } - - impl InMemoryConversationRepo { - fn insert_owner(&self, conversation_id: &str, user_id: UserId) { - self.owners - .lock() - .expect("lock owners") - .insert(conversation_id.to_string(), user_id); - } - } - - #[async_trait] - impl ConversationRepository for InMemoryConversationRepo { - async fn upsert_conversation( - &self, - _conversation_id: &str, - _user_id: UserId, - ) -> Result<(), ConversationError> { - Ok(()) - } - - async fn list_conversations( - &self, - _user_id: UserId, - ) -> Result, ConversationError> { - Ok(Vec::new()) - } - - async fn access_conversation( - &self, - conversation_id: &str, - user_id: UserId, - ) -> Result<(), ConversationError> { - let owners = self.owners.lock().expect("lock owners"); - match owners.get(conversation_id) { - Some(owner) if *owner == user_id => Ok(()), - Some(_) => Err(ConversationError::AccessDenied), - None => Err(ConversationError::NotFound), - } - } - - async fn get_conversation_owner( - &self, - conversation_id: &str, - ) -> Result, ConversationError> { - let owners = self.owners.lock().expect("lock owners"); - Ok(owners.get(conversation_id).copied()) - } - - async fn delete_conversation( - &self, - _conversation_id: &str, - _user_id: UserId, - ) -> Result<(), ConversationError> { - Ok(()) - } - } - - #[derive(Default)] - struct InMemoryShareRepo { - shares: Mutex>, - groups: Mutex>, - } - - impl InMemoryShareRepo { - fn next_share(&self, share: NewConversationShare) -> ConversationShare { - let now = Utc::now(); - ConversationShare { - id: Uuid::new_v4(), - conversation_id: share.conversation_id, - owner_user_id: share.owner_user_id, - share_type: share.share_type, - permission: share.permission, - recipient: share.recipient, - group_id: share.group_id, - org_email_pattern: share.org_email_pattern, - created_at: now, - updated_at: now, - } - } - - fn is_duplicate_share( - existing: &ConversationShare, - new_share: &NewConversationShare, - ) -> bool { - if existing.conversation_id != new_share.conversation_id { - return false; - } - - match new_share.share_type { - ShareType::Direct => { - existing.share_type == ShareType::Direct - && existing.recipient == new_share.recipient - } - ShareType::Group => { - existing.share_type == ShareType::Group - && existing.group_id == new_share.group_id - } - ShareType::Organization => { - existing.share_type == ShareType::Organization - && existing.org_email_pattern == new_share.org_email_pattern - } - ShareType::Public => existing.share_type == ShareType::Public, - } - } - } - - #[async_trait] - impl ConversationShareRepository for InMemoryShareRepo { - async fn create_group( - &self, - owner_user_id: UserId, - name: &str, - members: &[ShareRecipient], - ) -> Result { - let now = Utc::now(); - let group = ShareGroup { - id: Uuid::new_v4(), - owner_user_id, - name: name.to_string(), - members: members.to_vec(), - created_at: now, - updated_at: now, - }; - - self.groups - .lock() - .expect("lock groups") - .insert(group.id, group.clone()); - - Ok(group) - } - - async fn list_groups( - &self, - owner_user_id: UserId, - ) -> Result, ConversationError> { - let groups = self - .groups - .lock() - .expect("lock groups") - .values() - .filter(|group| group.owner_user_id == owner_user_id) - .cloned() - .collect(); - Ok(groups) - } - - async fn list_groups_for_member( - &self, - member_identifiers: &[ShareRecipient], - ) -> Result, ConversationError> { - let groups = self - .groups - .lock() - .expect("lock groups") - .values() - .filter(|group| { - group.members.iter().any(|member| { - member_identifiers.iter().any(|identifier| { - member.kind == identifier.kind - && member.value.to_lowercase() == identifier.value.to_lowercase() - }) - }) - }) - .cloned() - .collect(); - Ok(groups) - } - - async fn get_group( - &self, - owner_user_id: UserId, - group_id: Uuid, - ) -> Result, ConversationError> { - let groups = self.groups.lock().expect("lock groups"); - Ok(groups - .get(&group_id) - .filter(|group| group.owner_user_id == owner_user_id) - .cloned()) - } - - async fn update_group( - &self, - owner_user_id: UserId, - group_id: Uuid, - name: Option<&str>, - members: Option<&[ShareRecipient]>, - ) -> Result { - let mut groups = self.groups.lock().expect("lock groups"); - let group = groups - .get_mut(&group_id) - .filter(|group| group.owner_user_id == owner_user_id) - .ok_or(ConversationError::NotFound)?; - - if let Some(name) = name { - group.name = name.to_string(); - } - if let Some(members) = members { - group.members = members.to_vec(); - } - group.updated_at = Utc::now(); - Ok(group.clone()) - } - - async fn delete_group( - &self, - owner_user_id: UserId, - group_id: Uuid, - ) -> Result<(), ConversationError> { - let mut groups = self.groups.lock().expect("lock groups"); - let existing = groups - .get(&group_id) - .filter(|group| group.owner_user_id == owner_user_id) - .cloned(); - match existing { - Some(_) => { - groups.remove(&group_id); - Ok(()) - } - None => Err(ConversationError::NotFound), - } - } - - async fn create_share( - &self, - share: NewConversationShare, - ) -> Result { - let mut shares = self.shares.lock().expect("lock shares"); - - // Check if a duplicate share already exists and update it - if let Some(existing_idx) = shares - .iter() - .position(|existing| Self::is_duplicate_share(existing, &share)) - { - // Update existing share - let existing = &mut shares[existing_idx]; - existing.permission = share.permission; - existing.updated_at = Utc::now(); - return Ok(existing.clone()); - } - - // Create new share - let new_share = self.next_share(share); - shares.push(new_share.clone()); - Ok(new_share) - } - - async fn create_shares_batch( - &self, - shares: Vec, - ) -> Result, ConversationError> { - let mut shares_vec = self.shares.lock().expect("lock shares"); - let mut results = Vec::with_capacity(shares.len()); - - for share in shares { - // Check if a duplicate share already exists and update it - if let Some(existing_idx) = shares_vec - .iter() - .position(|existing| Self::is_duplicate_share(existing, &share)) - { - // Update existing share - let existing = &mut shares_vec[existing_idx]; - existing.permission = share.permission; - existing.updated_at = Utc::now(); - results.push(existing.clone()); - } else { - // Create new share - let new_share = self.next_share(share); - shares_vec.push(new_share.clone()); - results.push(new_share); - } - } - - Ok(results) - } - - async fn list_shares( - &self, - owner_user_id: UserId, - conversation_id: &str, - ) -> Result, ConversationError> { - let shares = self - .shares - .lock() - .expect("lock shares") - .iter() - .filter(|share| { - share.owner_user_id == owner_user_id && share.conversation_id == conversation_id - }) - .cloned() - .collect(); - Ok(shares) - } - - async fn delete_share( - &self, - owner_user_id: UserId, - conversation_id: &str, - share_id: Uuid, - ) -> Result<(), ConversationError> { - let mut shares = self.shares.lock().expect("lock shares"); - let original_len = shares.len(); - shares.retain(|share| { - !(share.owner_user_id == owner_user_id - && share.conversation_id == conversation_id - && share.id == share_id) - }); - if shares.len() == original_len { - return Err(ConversationError::NotFound); - } - Ok(()) - } - - async fn get_share_permission_for_user( - &self, - conversation_id: &str, - email: &str, - near_accounts: &[String], - ) -> Result, ConversationError> { - let shares = self.shares.lock().expect("lock shares"); - let mut permissions = Vec::new(); - - for share in shares - .iter() - .filter(|share| share.conversation_id == conversation_id) - { - match share.share_type { - ShareType::Direct => { - if let Some(recipient) = &share.recipient { - match recipient.kind { - ShareRecipientKind::Email if recipient.value == email => { - permissions.push(share.permission); - } - ShareRecipientKind::NearAccount - if near_accounts.contains(&recipient.value) => - { - permissions.push(share.permission); - } - _ => {} - } - } - } - ShareType::Group => { - let groups = self.groups.lock().expect("lock groups"); - if let Some(group_id) = share.group_id { - if let Some(group) = groups.get(&group_id) { - let members = group - .members - .iter() - .map(|member| (member.kind, member.value.clone())) - .collect::>(); - if members.contains(&(ShareRecipientKind::Email, email.to_string())) - || near_accounts.iter().any(|account| { - members.contains(&( - ShareRecipientKind::NearAccount, - account.clone(), - )) - }) - { - permissions.push(share.permission); - } - } - } - } - ShareType::Organization => { - if let Some(pattern) = &share.org_email_pattern { - if email.ends_with(pattern.trim_start_matches("%@")) { - permissions.push(share.permission); - } - } - } - ShareType::Public => {} - } - } - - if permissions.contains(&SharePermission::Write) { - return Ok(Some(SharePermission::Write)); - } - - if permissions.is_empty() { - Ok(None) - } else { - Ok(Some(SharePermission::Read)) - } - } - - async fn get_public_share_by_conversation_id( - &self, - conversation_id: &str, - ) -> Result, ConversationError> { - let shares = self.shares.lock().expect("lock shares"); - Ok(shares - .iter() - .find(|share| { - share.share_type == ShareType::Public - && share.conversation_id == conversation_id - }) - .cloned()) - } - - async fn list_conversations_shared_with_user( - &self, - user_id: UserId, - email: &str, - near_accounts: &[String], - ) -> Result, ConversationError> { - let shares = self.shares.lock().expect("lock shares"); - let groups = self.groups.lock().expect("lock groups"); - let mut result: std::collections::HashMap = - std::collections::HashMap::new(); - - for share in shares.iter() { - // Exclude own conversations - if share.owner_user_id == user_id { - continue; - } - - let matches = match share.share_type { - ShareType::Direct => { - if let Some(recipient) = &share.recipient { - match recipient.kind { - ShareRecipientKind::Email => recipient.value == email, - ShareRecipientKind::NearAccount => { - near_accounts.contains(&recipient.value) - } - } - } else { - false - } - } - ShareType::Group => { - if let Some(group_id) = share.group_id { - if let Some(group) = groups.get(&group_id) { - group.members.iter().any(|member| match member.kind { - ShareRecipientKind::Email => member.value == email, - ShareRecipientKind::NearAccount => { - near_accounts.contains(&member.value) - } - }) - } else { - false - } - } else { - false - } - } - ShareType::Organization => { - if let Some(pattern) = &share.org_email_pattern { - email.ends_with(pattern.trim_start_matches("%@")) - } else { - false - } - } - ShareType::Public => false, - }; - - if matches { - let entry = result - .entry(share.conversation_id.clone()) - .or_insert(SharePermission::Read); - if share.permission == SharePermission::Write { - *entry = SharePermission::Write; - } - } - } - - Ok(result.into_iter().collect()) - } - } - - #[derive(Default)] - struct InMemoryUserRepo { - users: Mutex>, - linked_accounts: Mutex>>, - } - - impl InMemoryUserRepo { - fn insert_user(&self, user: User) { - self.users.lock().expect("lock users").insert(user.id, user); - } - - fn insert_linked_account(&self, user_id: UserId, account: LinkedOAuthAccount) { - self.linked_accounts - .lock() - .expect("lock linked accounts") - .entry(user_id) - .or_default() - .push(account); - } - } - - #[async_trait] - impl UserRepository for InMemoryUserRepo { - async fn get_user(&self, user_id: UserId) -> anyhow::Result> { - Ok(self - .users - .lock() - .expect("lock users") - .get(&user_id) - .cloned()) - } - - async fn get_user_by_email(&self, _email: &str) -> anyhow::Result> { - Ok(None) - } - - async fn create_user( - &self, - _email: String, - _name: Option, - _avatar_url: Option, - ) -> anyhow::Result { - unimplemented!("create_user not needed for tests"); - } - - async fn update_user( - &self, - _user_id: UserId, - _name: Option, - _avatar_url: Option, - ) -> anyhow::Result { - unimplemented!("update_user not needed for tests"); - } - - async fn delete_user_account( - &self, - _user_id: UserId, - _cloud_deleted_conversation_ids: &[String], - _cloud_deleted_file_ids: &[String], - ) -> Result<(), crate::user::ports::AccountDeletionError> { - Ok(()) - } - - async fn create_account_deletion_request( - &self, - _user_id: UserId, - ) -> Result< - crate::user::ports::AccountDeletionRequestResult, - crate::user::ports::AccountDeletionError, - > { - unimplemented!("create_account_deletion_request not needed for tests"); - } - - async fn delete_account_deletion_request( - &self, - _deletion_id: uuid::Uuid, - ) -> anyhow::Result<()> { - unimplemented!("delete_account_deletion_request not needed for tests"); - } - - async fn retry_failed_account_deletion( - &self, - _deletion_id: uuid::Uuid, - ) -> anyhow::Result> { - Ok(None) - } - - async fn restore_account_deletion_failed_needs_review( - &self, - _deletion_id: uuid::Uuid, - _last_error: String, - ) -> anyhow::Result<()> { - Ok(()) - } - - async fn get_account_deletion_by_user_id( - &self, - _user_id: UserId, - ) -> anyhow::Result> { - Ok(None) - } - - async fn get_account_deletion( - &self, - _deletion_id: uuid::Uuid, - ) -> anyhow::Result> { - Ok(None) - } - - async fn claim_account_deletion( - &self, - _deletion_id: uuid::Uuid, - _lease_seconds: i64, - ) -> anyhow::Result> { - Ok(None) - } - - async fn update_account_deletion_progress( - &self, - _deletion_id: uuid::Uuid, - _progress: serde_json::Value, - _lease_seconds: i64, - ) -> anyhow::Result<()> { - Ok(()) - } - - async fn mark_account_deletion_retrying( - &self, - _deletion_id: uuid::Uuid, - _last_error: String, - _progress: serde_json::Value, - ) -> anyhow::Result<()> { - Ok(()) - } - - async fn mark_account_deletion_completed( - &self, - _deletion_id: uuid::Uuid, - ) -> anyhow::Result<()> { - Ok(()) - } - - async fn mark_account_deletion_failed_needs_review( - &self, - _deletion_id: uuid::Uuid, - _last_error: String, - _progress: serde_json::Value, - ) -> anyhow::Result<()> { - Ok(()) - } - - async fn list_owned_conversation_ids( - &self, - _user_id: UserId, - ) -> anyhow::Result> { - Ok(Vec::new()) - } - - async fn list_owned_file_ids(&self, _user_id: UserId) -> anyhow::Result> { - Ok(Vec::new()) - } - - async fn list_account_deletions( - &self, - _status: Option, - _limit: i64, - _offset: i64, - ) -> anyhow::Result> { - Ok(Vec::new()) - } - - async fn validate_account_deletion_preconditions( - &self, - _user_id: UserId, - ) -> Result<(), crate::user::ports::AccountDeletionError> { - Ok(()) - } - - async fn get_linked_accounts( - &self, - user_id: UserId, - ) -> anyhow::Result> { - Ok(self - .linked_accounts - .lock() - .expect("lock linked accounts") - .get(&user_id) - .cloned() - .unwrap_or_default()) - } - - async fn link_oauth_account( - &self, - _user_id: UserId, - _provider: OAuthProvider, - _provider_user_id: String, - ) -> anyhow::Result<()> { - Ok(()) - } - - async fn find_user_by_oauth( - &self, - _provider: OAuthProvider, - _provider_user_id: &str, - ) -> anyhow::Result> { - Ok(None) - } - - async fn list_users(&self, _limit: i64, _offset: i64) -> anyhow::Result<(Vec, u64)> { - Ok((Vec::new(), 0)) - } - - async fn has_active_ban(&self, _user_id: UserId) -> anyhow::Result { - Ok(false) - } - - async fn create_user_ban( - &self, - _user_id: UserId, - _ban_type: crate::user::ports::BanType, - _reason: Option, - _expires_at: Option>, - ) -> anyhow::Result<()> { - Ok(()) - } - } - - #[async_trait] - impl UserService for InMemoryUserRepo { - async fn get_user_profile( - &self, - _user_id: UserId, - ) -> anyhow::Result { - unimplemented!("UserService not needed for tests"); - } - - async fn update_profile( - &self, - _user_id: UserId, - _name: Option, - _avatar_url: Option, - ) -> anyhow::Result { - unimplemented!("UserService not needed for tests"); - } - - async fn delete_account( - &self, - _user_id: UserId, - _cloud_deleted_conversation_ids: &[String], - _cloud_deleted_file_ids: &[String], - ) -> Result<(), crate::user::ports::AccountDeletionError> { - Ok(()) - } - - async fn create_account_deletion_request( - &self, - _user_id: UserId, - ) -> Result< - crate::user::ports::AccountDeletionRequestResult, - crate::user::ports::AccountDeletionError, - > { - unimplemented!("create_account_deletion_request not needed for tests"); - } - - async fn delete_account_deletion_request( - &self, - _deletion_id: uuid::Uuid, - ) -> anyhow::Result<()> { - unimplemented!("delete_account_deletion_request not needed for tests"); - } - - async fn retry_failed_account_deletion( - &self, - _deletion_id: uuid::Uuid, - ) -> anyhow::Result> { - Ok(None) - } - - async fn restore_account_deletion_failed_needs_review( - &self, - _deletion_id: uuid::Uuid, - _last_error: String, - ) -> anyhow::Result<()> { - Ok(()) - } - - async fn get_account_deletion( - &self, - _deletion_id: uuid::Uuid, - ) -> anyhow::Result> { - Ok(None) - } - - async fn is_account_deletion_requested(&self, _user_id: UserId) -> anyhow::Result { - Ok(false) - } - - async fn list_owned_conversation_ids( - &self, - _user_id: UserId, - ) -> anyhow::Result> { - Ok(Vec::new()) - } - - async fn list_owned_file_ids(&self, _user_id: UserId) -> anyhow::Result> { - Ok(Vec::new()) - } - - async fn list_account_deletions( - &self, - _status: Option, - _limit: i64, - _offset: i64, - ) -> anyhow::Result> { - Ok(Vec::new()) - } - - async fn validate_account_deletion_preconditions( - &self, - _user_id: UserId, - ) -> Result<(), crate::user::ports::AccountDeletionError> { - Ok(()) - } - - async fn check_user_status( - &self, - _user_id: UserId, - ) -> Result<(), crate::user::ports::UserStatusError> { - Ok(()) - } - - async fn list_users(&self, _limit: i64, _offset: i64) -> anyhow::Result<(Vec, u64)> { - Ok((Vec::new(), 0)) - } - - async fn has_active_ban(&self, _user_id: UserId) -> anyhow::Result { - Ok(false) - } - - async fn ban_user_for_duration( - &self, - _user_id: UserId, - _ban_type: crate::user::ports::BanType, - _reason: Option, - _duration: chrono::Duration, - ) -> anyhow::Result<()> { - Ok(()) - } - } - - fn build_user(email: &str) -> User { - User { - id: UserId::new(), - email: email.to_string(), - name: None, - avatar_url: None, - created_at: Utc::now(), - updated_at: Utc::now(), - } - } - - fn setup_service_with_owner( - conversation_id: &str, - owner_email: &str, - ) -> ( - ConversationShareServiceImpl, - Arc, - Arc, - Arc, - User, - ) { - let conversation_repo = Arc::new(InMemoryConversationRepo::default()); - let share_repo = Arc::new(InMemoryShareRepo::default()); - let user_repo = Arc::new(InMemoryUserRepo::default()); - let owner = build_user(owner_email); - conversation_repo.insert_owner(conversation_id, owner.id); - user_repo.insert_user(owner.clone()); - - ( - ConversationShareServiceImpl::new( - conversation_repo.clone(), - share_repo.clone(), - user_repo.clone(), - ), - conversation_repo, - share_repo, - user_repo, - owner, - ) - } - - #[tokio::test] - async fn ensure_access_allows_owner() { - let conversation_repo = Arc::new(InMemoryConversationRepo::default()); - let share_repo = Arc::new(InMemoryShareRepo::default()); - let user_repo = Arc::new(InMemoryUserRepo::default()); - - let user = build_user("owner@example.com"); - conversation_repo.insert_owner("conv_123", user.id); - user_repo.insert_user(user.clone()); - - let service = ConversationShareServiceImpl::new(conversation_repo, share_repo, user_repo); - - let result = service - .ensure_access("conv_123", user.id, SharePermission::Read) - .await; - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn ensure_access_uses_direct_share_write() { - let conversation_repo = Arc::new(InMemoryConversationRepo::default()); - let share_repo = Arc::new(InMemoryShareRepo::default()); - let user_repo = Arc::new(InMemoryUserRepo::default()); - - let user = build_user("sharee@example.com"); - user_repo.insert_user(user.clone()); - - let share = share_repo - .create_share(NewConversationShare { - conversation_id: "conv_456".to_string(), - owner_user_id: UserId::new(), - share_type: ShareType::Direct, - permission: SharePermission::Write, - recipient: Some(ShareRecipient { - kind: ShareRecipientKind::Email, - value: "sharee@example.com".to_string(), - }), - group_id: None, - org_email_pattern: None, - }) - .await - .expect("share create"); - - assert_eq!(share.permission, SharePermission::Write); - - let service = ConversationShareServiceImpl::new(conversation_repo, share_repo, user_repo); - - let result = service - .ensure_access("conv_456", user.id, SharePermission::Write) - .await; - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn create_public_share() { - let conversation_repo = Arc::new(InMemoryConversationRepo::default()); - let share_repo = Arc::new(InMemoryShareRepo::default()); - let user_repo = Arc::new(InMemoryUserRepo::default()); - - let owner = build_user("owner@example.com"); - conversation_repo.insert_owner("conv_public", owner.id); - user_repo.insert_user(owner.clone()); - - let service = ConversationShareServiceImpl::new(conversation_repo, share_repo, user_repo); - - let shares = service - .create_share( - owner.id, - "conv_public", - SharePermission::Read, - ShareTarget::Public, - ) - .await - .expect("create public share"); - - let share = shares.first().expect("share"); - assert_eq!(share.share_type, ShareType::Public); - assert_eq!(share.permission, SharePermission::Read); - } - - #[tokio::test] - async fn create_share_requires_existing_conversation() { - let conversation_repo = Arc::new(InMemoryConversationRepo::default()); - let share_repo = Arc::new(InMemoryShareRepo::default()); - let user_repo = Arc::new(InMemoryUserRepo::default()); - - let owner = build_user("owner@example.com"); - user_repo.insert_user(owner.clone()); - - let service = ConversationShareServiceImpl::new(conversation_repo, share_repo, user_repo); - - let err = service - .create_share( - owner.id, - "missing_conv", - SharePermission::Read, - ShareTarget::Public, - ) - .await - .expect_err("should fail when conversation missing"); - assert!(matches!(err, ConversationError::NotFound)); - } - - #[tokio::test] - async fn ensure_access_matches_group_members() { - let conversation_repo = Arc::new(InMemoryConversationRepo::default()); - let share_repo = Arc::new(InMemoryShareRepo::default()); - let user_repo = Arc::new(InMemoryUserRepo::default()); - - let owner = build_user("owner@example.com"); - let sharee = build_user("team@example.com"); - user_repo.insert_user(owner.clone()); - user_repo.insert_user(sharee.clone()); - - conversation_repo.insert_owner("conv_group", owner.id); - - let group = share_repo - .create_group( - owner.id, - "team", - &[ShareRecipient { - kind: ShareRecipientKind::Email, - value: "team@example.com".to_string(), - }], - ) - .await - .expect("create group"); - - share_repo - .create_share(NewConversationShare { - conversation_id: "conv_group".to_string(), - owner_user_id: owner.id, - share_type: ShareType::Group, - permission: SharePermission::Read, - recipient: None, - group_id: Some(group.id), - org_email_pattern: None, - }) - .await - .expect("create share"); - - let service = ConversationShareServiceImpl::new(conversation_repo, share_repo, user_repo); - - let result = service - .ensure_access("conv_group", sharee.id, SharePermission::Read) - .await; - - assert!(result.is_ok()); - } - - #[tokio::test] - async fn create_group_share_requires_owners_group() { - let (service, _conversation_repo, share_repo, _user_repo, owner) = - setup_service_with_owner("conv_group_owner", "owner@example.com"); - - let outsider = build_user("outsider@example.com"); - - let outsider_group = share_repo - .create_group( - outsider.id, - "outsiders", - &[ShareRecipient { - kind: ShareRecipientKind::Email, - value: "outsider@example.com".to_string(), - }], - ) - .await - .expect("outsider group"); - - let err = service - .create_share( - owner.id, - "conv_group_owner", - SharePermission::Read, - ShareTarget::Group(outsider_group.id), - ) - .await - .expect_err("should reject group owned by someone else"); - assert!(matches!(err, ConversationError::AccessDenied)); - } - - #[tokio::test] - async fn ensure_access_denies_group_non_member() { - let (service, _conversation_repo, share_repo, user_repo, owner) = - setup_service_with_owner("conv_group_access", "owner@example.com"); - - let sharee = build_user("someone@example.com"); - user_repo.insert_user(sharee.clone()); - - let group = share_repo - .create_group( - owner.id, - "team", - &[ShareRecipient { - kind: ShareRecipientKind::Email, - value: "member@example.com".to_string(), - }], - ) - .await - .expect("create group"); - - share_repo - .create_share(NewConversationShare { - conversation_id: "conv_group_access".to_string(), - owner_user_id: owner.id, - share_type: ShareType::Group, - permission: SharePermission::Read, - recipient: None, - group_id: Some(group.id), - org_email_pattern: None, - }) - .await - .expect("create share"); - - let err = service - .ensure_access("conv_group_access", sharee.id, SharePermission::Read) - .await - .expect_err("non member should not access"); - assert!(matches!(err, ConversationError::AccessDenied)); - } - - #[tokio::test] - async fn ensure_access_prefers_write_when_multiple_shares() { - let conversation_repo = Arc::new(InMemoryConversationRepo::default()); - let share_repo = Arc::new(InMemoryShareRepo::default()); - let user_repo = Arc::new(InMemoryUserRepo::default()); - let owner = build_user("owner@example.com"); - let sharee = build_user("writer@example.com"); - conversation_repo.insert_owner("conv_write", owner.id); - user_repo.insert_user(owner.clone()); - user_repo.insert_user(sharee.clone()); - - let service = ConversationShareServiceImpl::new( - conversation_repo.clone(), - share_repo.clone(), - user_repo.clone(), - ); - - share_repo - .create_share(NewConversationShare { - conversation_id: "conv_write".to_string(), - owner_user_id: owner.id, - share_type: ShareType::Direct, - permission: SharePermission::Read, - recipient: Some(ShareRecipient { - kind: ShareRecipientKind::Email, - value: "writer@example.com".to_string(), - }), - group_id: None, - org_email_pattern: None, - }) - .await - .expect("create read share"); - - share_repo - .create_share(NewConversationShare { - conversation_id: "conv_write".to_string(), - owner_user_id: owner.id, - share_type: ShareType::Direct, - permission: SharePermission::Write, - recipient: Some(ShareRecipient { - kind: ShareRecipientKind::NearAccount, - value: "writer.near".to_string(), - }), - group_id: None, - org_email_pattern: None, - }) - .await - .expect("create write share"); - - user_repo.insert_linked_account( - sharee.id, - LinkedOAuthAccount { - provider: OAuthProvider::Near, - provider_user_id: "writer.near".to_string(), - linked_at: Utc::now(), - }, - ); - - service - .ensure_access("conv_write", sharee.id, SharePermission::Write) - .await - .expect("should allow write with matching share"); - } - - #[tokio::test] - async fn ensure_access_denies_write_when_only_read_share() { - let conversation_repo = Arc::new(InMemoryConversationRepo::default()); - let share_repo = Arc::new(InMemoryShareRepo::default()); - let user_repo = Arc::new(InMemoryUserRepo::default()); - - let user = build_user("reader@example.com"); - user_repo.insert_user(user.clone()); - - share_repo - .create_share(NewConversationShare { - conversation_id: "conv_read".to_string(), - owner_user_id: UserId::new(), - share_type: ShareType::Direct, - permission: SharePermission::Read, - recipient: Some(ShareRecipient { - kind: ShareRecipientKind::Email, - value: "reader@example.com".to_string(), - }), - group_id: None, - org_email_pattern: None, - }) - .await - .expect("share create"); - - let service = ConversationShareServiceImpl::new(conversation_repo, share_repo, user_repo); - - let result = service - .ensure_access("conv_read", user.id, SharePermission::Write) - .await; - - assert!(matches!(result, Err(ConversationError::AccessDenied))); - } - - #[tokio::test] - async fn ensure_access_respects_near_account_recipients() { - let conversation_repo = Arc::new(InMemoryConversationRepo::default()); - let share_repo = Arc::new(InMemoryShareRepo::default()); - let user_repo = Arc::new(InMemoryUserRepo::default()); - - let user = build_user("linked@example.com"); - user_repo.insert_user(user.clone()); - user_repo.insert_linked_account( - user.id, - LinkedOAuthAccount { - provider: OAuthProvider::Near, - provider_user_id: "alice.near".to_string(), - linked_at: Utc::now(), - }, - ); - - share_repo - .create_share(NewConversationShare { - conversation_id: "conv_near".to_string(), - owner_user_id: UserId::new(), - share_type: ShareType::Direct, - permission: SharePermission::Read, - recipient: Some(ShareRecipient { - kind: ShareRecipientKind::NearAccount, - value: "alice.near".to_string(), - }), - group_id: None, - org_email_pattern: None, - }) - .await - .expect("share create"); - - let service = ConversationShareServiceImpl::new(conversation_repo, share_repo, user_repo); - - service - .ensure_access("conv_near", user.id, SharePermission::Read) - .await - .expect("near access"); - } - - #[tokio::test] - async fn ensure_access_respects_org_shares() { - let conversation_repo = Arc::new(InMemoryConversationRepo::default()); - let share_repo = Arc::new(InMemoryShareRepo::default()); - let user_repo = Arc::new(InMemoryUserRepo::default()); - - let user = build_user("someone@team.example.com"); - user_repo.insert_user(user.clone()); - - share_repo - .create_share(NewConversationShare { - conversation_id: "conv_org".to_string(), - owner_user_id: UserId::new(), - share_type: ShareType::Organization, - permission: SharePermission::Read, - recipient: None, - group_id: None, - org_email_pattern: Some("%@example.com".to_string()), - }) - .await - .expect("share create"); - - let service = ConversationShareServiceImpl::new(conversation_repo, share_repo, user_repo); - - service - .ensure_access("conv_org", user.id, SharePermission::Read) - .await - .expect("org access"); - } - - #[tokio::test] - async fn create_share_normalizes_recipients_and_patterns() { - let (service, _conversation_repo, _share_repo, _user_repo, owner) = - setup_service_with_owner("conv_norm", "owner@example.com"); - - let shares = service - .create_share( - owner.id, - "conv_norm", - SharePermission::Read, - ShareTarget::Direct(vec![ - ShareRecipient { - kind: ShareRecipientKind::Email, - value: " MixedCase@Example.COM ".to_string(), - }, - ShareRecipient { - kind: ShareRecipientKind::NearAccount, - value: " alice.near ".to_string(), - }, - ]), - ) - .await - .expect("create direct shares"); - - assert_eq!(shares.len(), 2); - assert_eq!( - shares[0].recipient.as_ref().expect("email recipient").value, - "mixedcase@example.com" - ); - assert_eq!( - shares[1].recipient.as_ref().expect("near recipient").value, - "alice.near" - ); - - service - .create_share( - owner.id, - "conv_norm", - SharePermission::Read, - ShareTarget::Organization("example.com".to_string()), - ) - .await - .expect("create org share"); - - let stored = service - .list_shares(owner.id, "conv_norm") - .await - .expect("list shares"); - let org_share = stored - .iter() - .find(|share| share.share_type == ShareType::Organization) - .expect("org share missing"); - assert_eq!( - org_share.org_email_pattern.as_deref(), - Some("%@example.com") - ); - } - - #[tokio::test] - async fn organization_patterns_preserve_existing_wildcards() { - let (service, _conversation_repo, _share_repo, _user_repo, owner) = - setup_service_with_owner("conv_org_norm", "owner@example.com"); - - service - .create_share( - owner.id, - "conv_org_norm", - SharePermission::Read, - ShareTarget::Organization("%@partner.example.com".to_string()), - ) - .await - .expect("create org share"); - - let stored = service - .list_shares(owner.id, "conv_org_norm") - .await - .expect("list shares"); - let org_share = stored - .iter() - .find(|share| share.share_type == ShareType::Organization) - .expect("org share missing"); - assert_eq!( - org_share.org_email_pattern.as_deref(), - Some("%@partner.example.com") - ); - } - - #[tokio::test] - async fn organization_patterns_normalize_at_prefix() { - let (service, _conversation_repo, _share_repo, _user_repo, owner) = - setup_service_with_owner("conv_org_at", "owner@example.com"); - - // @company.com should be normalized to %@company.com - service - .create_share( - owner.id, - "conv_org_at", - SharePermission::Read, - ShareTarget::Organization("@acme.com".to_string()), - ) - .await - .expect("create org share"); - - let stored = service - .list_shares(owner.id, "conv_org_at") - .await - .expect("list shares"); - let org_share = stored - .iter() - .find(|share| share.share_type == ShareType::Organization) - .expect("org share missing"); - assert_eq!(org_share.org_email_pattern.as_deref(), Some("%@acme.com")); - } - - #[tokio::test] - async fn share_group_lifecycle_normalizes_members() { - let (service, _conversation_repo, _share_repo, _user_repo, owner) = - setup_service_with_owner("conv_groups", "owner@example.com"); - - let group = service - .create_group( - owner.id, - " Team ", - vec![ShareRecipient { - kind: ShareRecipientKind::Email, - value: " TEAM@Example.Com ".to_string(), - }], - ) - .await - .expect("create group"); - - assert_eq!(group.name, " Team "); - assert_eq!(group.members.len(), 1); - assert_eq!(group.members[0].value, "team@example.com"); - - let groups = service.list_groups(owner.id).await.expect("list groups"); - assert_eq!(groups.len(), 1); - - let updated = service - .update_group( - owner.id, - group.id, - Some("Renamed Group".to_string()), - Some(vec![ShareRecipient { - kind: ShareRecipientKind::NearAccount, - value: " alice.near ".to_string(), - }]), - ) - .await - .expect("update group"); - - assert_eq!(updated.name, "Renamed Group"); - assert_eq!(updated.members[0].value, "alice.near"); - - service - .delete_group(owner.id, group.id) - .await - .expect("delete group"); - - let remaining = service.list_groups(owner.id).await.expect("list groups"); - assert!(remaining.is_empty()); - } - - #[tokio::test] - async fn list_accessible_groups_includes_member_groups() { - let (service, _conversation_repo, share_repo, user_repo, owner) = - setup_service_with_owner("conv_accessible", "owner@example.com"); - - // Create a group owned by the owner with another member - let group1 = service - .create_group( - owner.id, - "Owner's Group", - vec![ShareRecipient { - kind: ShareRecipientKind::Email, - value: "member@example.com".to_string(), - }], - ) - .await - .expect("create group 1"); - - // Create another user who owns a different group that includes the owner - let other_owner = build_user("other@example.com"); - user_repo.insert_user(other_owner.clone()); - - let group2 = share_repo - .create_group( - other_owner.id, - "Other's Group", - &[ShareRecipient { - kind: ShareRecipientKind::Email, - value: "owner@example.com".to_string(), - }], - ) - .await - .expect("create group 2"); - - // The owner should see both groups when listing accessible groups - let member_identifiers = vec![ShareRecipient { - kind: ShareRecipientKind::Email, - value: "owner@example.com".to_string(), - }]; - - let accessible = service - .list_accessible_groups(owner.id, &member_identifiers) - .await - .expect("list accessible groups"); - - assert_eq!(accessible.len(), 2); - let group_ids: Vec<_> = accessible.iter().map(|g| g.id).collect(); - assert!(group_ids.contains(&group1.id)); - assert!(group_ids.contains(&group2.id)); - - // The other owner should only see their owned group (and not member groups of owner) - let other_member_identifiers = vec![ShareRecipient { - kind: ShareRecipientKind::Email, - value: "other@example.com".to_string(), - }]; - - let other_accessible = service - .list_accessible_groups(other_owner.id, &other_member_identifiers) - .await - .expect("list accessible groups for other"); - - assert_eq!(other_accessible.len(), 1); - assert_eq!(other_accessible[0].id, group2.id); - } - - #[tokio::test] - async fn delete_share_requires_matching_conversation() { - let (service, _conversation_repo, _share_repo, _user_repo, owner) = - setup_service_with_owner("conv_consistent", "owner@example.com"); - - let share = service - .create_share( - owner.id, - "conv_consistent", - SharePermission::Read, - ShareTarget::Public, - ) - .await - .expect("create share") - .into_iter() - .next() - .expect("share"); - - let err = service - .delete_share(owner.id, "other_conversation", share.id) - .await - .expect_err("should fail mismatch"); - assert!(matches!(err, ConversationError::NotFound)); - - let shares = service - .list_shares(owner.id, "conv_consistent") - .await - .expect("list shares"); - assert_eq!(shares.len(), 1, "share should remain untouched"); - } - - #[tokio::test] - async fn delete_share_requires_owner() { - let (service, _conversation_repo, share_repo, user_repo, owner) = - setup_service_with_owner("conv_protected", "owner@example.com"); - - let other_user = build_user("intruder@example.com"); - user_repo.insert_user(other_user.clone()); - - let share = service - .create_share( - owner.id, - "conv_protected", - SharePermission::Read, - ShareTarget::Public, - ) - .await - .expect("create share") - .into_iter() - .next() - .expect("share"); - - let err = service - .delete_share(other_user.id, "conv_protected", share.id) - .await - .expect_err("should fail for non-owner"); - assert!(matches!(err, ConversationError::AccessDenied)); - - // Avoid warnings about unused repos - drop((share_repo, user_repo)); - } - - #[tokio::test] - async fn delete_share_removes_share_for_owner() { - let (service, _conversation_repo, _share_repo, user_repo, owner) = - setup_service_with_owner("conv_delete_success", "owner@example.com"); - - let sharee = build_user("reader@example.com"); - user_repo.insert_user(sharee.clone()); - - let share = service - .create_share( - owner.id, - "conv_delete_success", - SharePermission::Read, - ShareTarget::Direct(vec![ShareRecipient { - kind: ShareRecipientKind::Email, - value: sharee.email.clone(), - }]), - ) - .await - .expect("create share") - .into_iter() - .next() - .expect("share"); - - service - .delete_share(owner.id, "conv_delete_success", share.id) - .await - .expect("owner should delete share"); - - let shares = service - .list_shares(owner.id, "conv_delete_success") - .await - .expect("list shares"); - assert!(shares.is_empty(), "share should be removed after deletion"); - } - - #[tokio::test] - async fn delete_share_revokes_recipient_access() { - let (service, _conversation_repo, _share_repo, user_repo, owner) = - setup_service_with_owner("conv_revoke_access", "owner@example.com"); - - let sharee = build_user("reader@example.com"); - user_repo.insert_user(sharee.clone()); - - let share = service - .create_share( - owner.id, - "conv_revoke_access", - SharePermission::Read, - ShareTarget::Direct(vec![ShareRecipient { - kind: ShareRecipientKind::Email, - value: sharee.email.clone(), - }]), - ) - .await - .expect("create share") - .into_iter() - .next() - .expect("share"); - - service - .ensure_access("conv_revoke_access", sharee.id, SharePermission::Read) - .await - .expect("recipient should have access"); - - service - .delete_share(owner.id, "conv_revoke_access", share.id) - .await - .expect("owner should delete share"); - - let err = service - .ensure_access("conv_revoke_access", sharee.id, SharePermission::Read) - .await - .expect_err("access should be revoked"); - assert!(matches!(err, ConversationError::AccessDenied)); - } - - #[tokio::test] - async fn editor_can_share_and_owner_lists_it() { - let conversation_id = "conv_editor_share"; - let (service, _conversation_repo, share_repo, user_repo, owner) = - setup_service_with_owner(conversation_id, "owner@example.com"); - - // Create an editor user and grant them write access via a direct share. - let editor = build_user("editor@example.com"); - user_repo.insert_user(editor.clone()); - - share_repo - .create_share(NewConversationShare { - conversation_id: conversation_id.to_string(), - owner_user_id: owner.id, - share_type: ShareType::Direct, - permission: SharePermission::Write, - recipient: Some(ShareRecipient { - kind: ShareRecipientKind::Email, - value: editor.email.clone(), - }), - group_id: None, - org_email_pattern: None, - }) - .await - .expect("grant editor write access"); - - // Editor shares the conversation with a new recipient. - let sharee = build_user("newsharee@example.com"); - user_repo.insert_user(sharee.clone()); - - let created = service - .create_share( - editor.id, - conversation_id, - SharePermission::Read, - ShareTarget::Direct(vec![ShareRecipient { - kind: ShareRecipientKind::Email, - value: sharee.email.clone(), - }]), - ) - .await - .expect("editor should be able to create share"); - - assert_eq!(created.len(), 1); - assert_eq!( - created[0].owner_user_id, owner.id, - "shares should be stored under the conversation owner" - ); - - // Owner's shares list should include the newly shared recipient. - let listed = service - .list_shares(owner.id, conversation_id) - .await - .expect("list shares"); - assert!( - listed - .iter() - .any(|share| share.recipient.as_ref().is_some_and(|r| r.kind - == ShareRecipientKind::Email - && r.value == sharee.email)), - "owner shares list should include the new sharee" - ); - - // And the sharee should have access. - service - .ensure_access(conversation_id, sharee.id, SharePermission::Read) - .await - .expect("sharee should have read access"); - } - - #[tokio::test] - async fn editor_with_write_can_delete_shares() { - let conversation_id = "conv_editor_delete"; - let (service, _conversation_repo, share_repo, user_repo, owner) = - setup_service_with_owner(conversation_id, "owner@example.com"); - - // Grant editor write access - let editor = build_user("editor2@example.com"); - user_repo.insert_user(editor.clone()); - share_repo - .create_share(NewConversationShare { - conversation_id: conversation_id.to_string(), - owner_user_id: owner.id, - share_type: ShareType::Direct, - permission: SharePermission::Write, - recipient: Some(ShareRecipient { - kind: ShareRecipientKind::Email, - value: editor.email.clone(), - }), - group_id: None, - org_email_pattern: None, - }) - .await - .expect("grant editor write access"); - - // Owner shares with a sharee - let sharee = build_user("todelete@example.com"); - user_repo.insert_user(sharee.clone()); - let share = service - .create_share( - owner.id, - conversation_id, - SharePermission::Read, - ShareTarget::Direct(vec![ShareRecipient { - kind: ShareRecipientKind::Email, - value: sharee.email.clone(), - }]), - ) - .await - .expect("create share") - .into_iter() - .next() - .expect("share"); - - service - .ensure_access(conversation_id, sharee.id, SharePermission::Read) - .await - .expect("sharee should initially have access"); - - // Editor deletes the share - service - .delete_share(editor.id, conversation_id, share.id) - .await - .expect("editor should delete share with write access"); - - let remaining = service - .list_shares(owner.id, conversation_id) - .await - .expect("list shares"); - assert!( - remaining.iter().all(|s| s.id != share.id), - "deleted share should not be present in shares list" - ); - assert!( - remaining - .iter() - .all(|s| { s.recipient.as_ref().is_none_or(|r| r.value != sharee.email) }), - "deleted sharee recipient should not be present in shares list" - ); - - let err = service - .ensure_access(conversation_id, sharee.id, SharePermission::Read) - .await - .expect_err("access should be revoked after deletion"); - assert!(matches!(err, ConversationError::AccessDenied)); - } - - #[tokio::test] - async fn ensure_access_denies_missing_user_record() { - let (service, _conversation_repo, _share_repo, _user_repo, owner) = - setup_service_with_owner("conv_missing_user", "owner@example.com"); - - let ghost_user = build_user("ghost@example.com"); - - service - .create_share( - owner.id, - "conv_missing_user", - SharePermission::Read, - ShareTarget::Direct(vec![ShareRecipient { - kind: ShareRecipientKind::Email, - value: ghost_user.email.clone(), - }]), - ) - .await - .expect("create share"); - - let err = service - .ensure_access("conv_missing_user", ghost_user.id, SharePermission::Read) - .await - .expect_err("missing user should be denied"); - assert!(matches!(err, ConversationError::AccessDenied)); - } - - #[tokio::test] - async fn public_access_by_conversation_id_allows_read_when_public() { - let (service, _conversation_repo, _share_repo, _user_repo, owner) = - setup_service_with_owner("conv_public_read", "owner@example.com"); - - // Create a public share with read permission - service - .create_share( - owner.id, - "conv_public_read", - SharePermission::Read, - ShareTarget::Public, - ) - .await - .expect("create public share"); - - // Public access by conversation ID should work for read - let share = service - .get_public_access_by_conversation_id("conv_public_read", SharePermission::Read) - .await - .expect("public read access should succeed"); - - assert_eq!(share.conversation_id, "conv_public_read"); - assert_eq!(share.permission, SharePermission::Read); - } - - #[tokio::test] - async fn public_access_by_conversation_id_denied_when_not_public() { - let (service, _conversation_repo, _share_repo, _user_repo, _owner) = - setup_service_with_owner("conv_private", "owner@example.com"); - - // No public share created - should be denied - let err = service - .get_public_access_by_conversation_id("conv_private", SharePermission::Read) - .await - .expect_err("should be denied for private conversation"); - - assert!(matches!(err, ConversationError::NotFound)); - } - - #[tokio::test] - async fn public_access_by_conversation_id_denied_for_nonexistent() { - let (service, _conversation_repo, _share_repo, _user_repo, _owner) = - setup_service_with_owner("conv_exists", "owner@example.com"); - - // Non-existent conversation should return NotFound - let err = service - .get_public_access_by_conversation_id("conv_nonexistent", SharePermission::Read) - .await - .expect_err("should be denied for nonexistent conversation"); - - assert!(matches!(err, ConversationError::NotFound)); - } - - #[tokio::test] - async fn public_access_enforces_write_permission() { - let (service, _conversation_repo, _share_repo, _user_repo, owner) = - setup_service_with_owner("conv_public_write", "owner@example.com"); - - // Create a public share with write permission - service - .create_share( - owner.id, - "conv_public_write", - SharePermission::Write, - ShareTarget::Public, - ) - .await - .expect("create public share"); - - // Both read and write access should work - service - .get_public_access_by_conversation_id("conv_public_write", SharePermission::Read) - .await - .expect("public read access should succeed"); - - service - .get_public_access_by_conversation_id("conv_public_write", SharePermission::Write) - .await - .expect("public write access should succeed"); - } - - #[tokio::test] - async fn public_access_denies_write_when_only_read() { - let (service, _conversation_repo, _share_repo, _user_repo, owner) = - setup_service_with_owner("conv_public_readonly", "owner@example.com"); - - // Create a public share with read-only permission - service - .create_share( - owner.id, - "conv_public_readonly", - SharePermission::Read, - ShareTarget::Public, - ) - .await - .expect("create public share"); - - // Read should work - service - .get_public_access_by_conversation_id("conv_public_readonly", SharePermission::Read) - .await - .expect("public read access should succeed"); - - // Write should be denied - let err = service - .get_public_access_by_conversation_id("conv_public_readonly", SharePermission::Write) - .await - .expect_err("write access should be denied"); - - assert!(matches!(err, ConversationError::AccessDenied)); - } - - #[tokio::test] - async fn duplicate_public_share_updates_permission() { - let (service, _conversation_repo, _share_repo, _user_repo, owner) = - setup_service_with_owner("conv_dup_public", "owner@example.com"); - - // Create initial public share with read permission - let shares = service - .create_share( - owner.id, - "conv_dup_public", - SharePermission::Read, - ShareTarget::Public, - ) - .await - .expect("create public share"); - - assert_eq!(shares.len(), 1); - assert_eq!(shares[0].permission, SharePermission::Read); - let share_id = shares[0].id; - - // Create duplicate public share with write permission - should update - let shares = service - .create_share( - owner.id, - "conv_dup_public", - SharePermission::Write, - ShareTarget::Public, - ) - .await - .expect("duplicate public share should update"); - - assert_eq!(shares.len(), 1); - assert_eq!(shares[0].permission, SharePermission::Write); - assert_eq!(shares[0].id, share_id); // Same share ID - - // Verify only one share exists - let all_shares = service - .list_shares(owner.id, "conv_dup_public") - .await - .expect("list shares"); - assert_eq!(all_shares.len(), 1); - assert_eq!(all_shares[0].permission, SharePermission::Write); - } - - #[tokio::test] - async fn duplicate_direct_share_updates_permission() { - let (service, _conversation_repo, _share_repo, _user_repo, owner) = - setup_service_with_owner("conv_dup_direct", "owner@example.com"); - - let recipient = vec![ShareRecipient { - kind: ShareRecipientKind::Email, - value: "user@example.com".to_string(), - }]; - - // Create initial direct share with read permission - let shares = service - .create_share( - owner.id, - "conv_dup_direct", - SharePermission::Read, - ShareTarget::Direct(recipient.clone()), - ) - .await - .expect("create direct share"); - - assert_eq!(shares.len(), 1); - assert_eq!(shares[0].permission, SharePermission::Read); - let share_id = shares[0].id; - - // Create duplicate direct share with write permission - should update - let shares = service - .create_share( - owner.id, - "conv_dup_direct", - SharePermission::Write, - ShareTarget::Direct(recipient), - ) - .await - .expect("duplicate direct share should update"); - - assert_eq!(shares.len(), 1); - assert_eq!(shares[0].permission, SharePermission::Write); - assert_eq!(shares[0].id, share_id); // Same share ID - - // Verify only one share exists - let all_shares = service - .list_shares(owner.id, "conv_dup_direct") - .await - .expect("list shares"); - assert_eq!(all_shares.len(), 1); - assert_eq!(all_shares[0].permission, SharePermission::Write); - } - - #[tokio::test] - async fn duplicate_group_share_updates_permission() { - let (service, _conversation_repo, _share_repo, _user_repo, owner) = - setup_service_with_owner("conv_dup_group", "owner@example.com"); - - let group = service - .create_group( - owner.id, - "Test Group", - vec![ShareRecipient { - kind: ShareRecipientKind::Email, - value: "member@example.com".to_string(), - }], - ) - .await - .expect("create group"); - - // Create initial group share with read permission - let shares = service - .create_share( - owner.id, - "conv_dup_group", - SharePermission::Read, - ShareTarget::Group(group.id), - ) - .await - .expect("create group share"); - - assert_eq!(shares.len(), 1); - assert_eq!(shares[0].permission, SharePermission::Read); - let share_id = shares[0].id; - - // Create duplicate group share with write permission - should update - let shares = service - .create_share( - owner.id, - "conv_dup_group", - SharePermission::Write, - ShareTarget::Group(group.id), - ) - .await - .expect("duplicate group share should update"); - - assert_eq!(shares.len(), 1); - assert_eq!(shares[0].permission, SharePermission::Write); - assert_eq!(shares[0].id, share_id); // Same share ID - - // Verify only one share exists - let all_shares = service - .list_shares(owner.id, "conv_dup_group") - .await - .expect("list shares"); - assert_eq!(all_shares.len(), 1); - assert_eq!(all_shares[0].permission, SharePermission::Write); - } - - #[tokio::test] - async fn duplicate_organization_share_updates_permission() { - let (service, _conversation_repo, _share_repo, _user_repo, owner) = - setup_service_with_owner("conv_dup_org", "owner@example.com"); - - // Create initial organization share with read permission - let shares = service - .create_share( - owner.id, - "conv_dup_org", - SharePermission::Read, - ShareTarget::Organization("@example.com".to_string()), - ) - .await - .expect("create org share"); - - assert_eq!(shares.len(), 1); - assert_eq!(shares[0].permission, SharePermission::Read); - let share_id = shares[0].id; - - // Create duplicate organization share with write permission - should update - let shares = service - .create_share( - owner.id, - "conv_dup_org", - SharePermission::Write, - ShareTarget::Organization("@example.com".to_string()), - ) - .await - .expect("duplicate org share should update"); - - assert_eq!(shares.len(), 1); - assert_eq!(shares[0].permission, SharePermission::Write); - assert_eq!(shares[0].id, share_id); // Same share ID - - // Verify only one share exists - let all_shares = service - .list_shares(owner.id, "conv_dup_org") - .await - .expect("list shares"); - assert_eq!(all_shares.len(), 1); - assert_eq!(all_shares[0].permission, SharePermission::Write); - } -} diff --git a/crates/services/src/file/mod.rs b/crates/services/src/file/mod.rs deleted file mode 100644 index 83408c78..00000000 --- a/crates/services/src/file/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod ports; -pub mod service; diff --git a/crates/services/src/file/ports.rs b/crates/services/src/file/ports.rs deleted file mode 100644 index 1fc622fa..00000000 --- a/crates/services/src/file/ports.rs +++ /dev/null @@ -1,83 +0,0 @@ -use async_trait::async_trait; -use serde::{Deserialize, Serialize}; - -use crate::UserId; - -/// File data structure for tracking files (internal and list response) -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] -pub struct FileData { - pub id: String, - pub bytes: i64, - pub created_at: i64, - #[serde(skip_serializing_if = "Option::is_none")] - pub expires_at: Option, - pub filename: String, - pub purpose: String, -} - -#[derive(Debug, thiserror::Error)] -pub enum FileError { - #[error("Database error: {0}")] - DatabaseError(String), - #[error("File not found")] - NotFound, - #[error("OpenAI API error: {0}")] - ApiError(String), - #[error("Access denied")] - AccessDenied, -} - -#[async_trait] -pub trait FileRepository: Send + Sync { - /// Store complete file object for a user - async fn upsert_file(&self, file: &FileData, user_id: UserId) -> Result<(), FileError>; - - /// Get a file object by ID - async fn get_file(&self, file_id: &str, user_id: UserId) -> Result; - - /// List file objects for a user with pagination - async fn list_files( - &self, - user_id: UserId, - after: Option, - limit: i64, - order: &str, - purpose: Option, - ) -> Result, FileError>; - - /// Check if a file exists for a user - async fn access_file(&self, file_id: &str, user_id: UserId) -> Result<(), FileError>; - - /// Delete a file for a user - async fn delete_file(&self, file_id: &str, user_id: UserId) -> Result<(), FileError>; -} - -#[async_trait] -pub trait FileService: Send + Sync { - /// Track a file by storing complete information - async fn track_file(&self, file: FileData, user_id: UserId) -> Result<(), FileError>; - - /// List files for a user with pagination from local database - async fn list_files( - &self, - user_id: UserId, - after: Option, - limit: i64, - order: &str, - purpose: Option, - ) -> Result<(Vec, bool), FileError>; - - /// Get a file from local database (checks user access) - async fn get_file(&self, file_id: &str, user_id: UserId) -> Result; - - /// Ensure the user has access to a file using only the local database - async fn access_file(&self, file_id: &str, user_id: UserId) -> Result<(), FileError>; - - /// Delete a file for a user - async fn delete_file( - &self, - file_id: &str, - user_id: UserId, - ) -> Result; -} diff --git a/crates/services/src/file/service.rs b/crates/services/src/file/service.rs deleted file mode 100644 index e5ae0936..00000000 --- a/crates/services/src/file/service.rs +++ /dev/null @@ -1,169 +0,0 @@ -use super::ports::{FileData, FileError, FileRepository, FileService}; -use crate::response::ports::OpenAIProxyService; -use crate::UserId; -use async_trait::async_trait; -use bytes::Bytes; -use futures::TryStreamExt; -use http::Method; -use std::sync::Arc; - -pub struct FileServiceImpl { - repository: Arc, - openai_proxy: Arc, -} - -impl FileServiceImpl { - pub fn new( - repository: Arc, - openai_proxy: Arc, - ) -> Self { - Self { - repository, - openai_proxy, - } - } -} - -#[async_trait] -impl FileService for FileServiceImpl { - async fn track_file(&self, file: FileData, user_id: UserId) -> Result<(), FileError> { - tracing::info!("Tracking file: file_id={}, user_id={}", file.id, user_id); - - // Store complete file object in database - self.repository.upsert_file(&file, user_id).await?; - - tracing::info!( - "File tracked successfully: file_id={}, user_id={}", - file.id, - user_id - ); - - Ok(()) - } - - async fn list_files( - &self, - user_id: UserId, - after: Option, - limit: i64, - order: &str, - purpose: Option, - ) -> Result<(Vec, bool), FileError> { - tracing::info!( - "Listing files with pagination for user_id={}, after={:?}, limit={}, order={}", - user_id, - after, - limit, - order - ); - - // Fetch limit + 1 to determine if there are more results - let fetch_limit = limit + 1; - - // Get files directly from database with pagination - let files = self - .repository - .list_files(user_id, after, fetch_limit, order, purpose) - .await?; - - tracing::info!( - "Retrieved {} file(s) from database for user_id={}", - files.len(), - user_id - ); - - // Determine if there are more results - let has_more = files.len() > limit as usize; - let files_to_return: Vec<_> = files.into_iter().take(limit as usize).collect(); - - Ok((files_to_return, has_more)) - } - - async fn get_file(&self, file_id: &str, user_id: UserId) -> Result { - tracing::info!("Getting file: file_id={}, user_id={}", file_id, user_id); - - // Get file directly from database - let file = self.repository.get_file(file_id, user_id).await?; - - tracing::debug!( - "Retrieved file {} from database for user {}", - file_id, - user_id - ); - - Ok(file) - } - - async fn access_file(&self, file_id: &str, user_id: UserId) -> Result<(), FileError> { - self.repository.access_file(file_id, user_id).await - } - - async fn delete_file( - &self, - file_id: &str, - user_id: UserId, - ) -> Result { - tracing::info!("Deleting file: file_id={}, user_id={}", file_id, user_id); - - self.access_file(file_id, user_id).await?; - - // First delete file from OpenAI - let deleted = self.delete_file_from_openai(file_id).await?; - - // Then delete from database - self.repository.delete_file(file_id, user_id).await?; - - tracing::info!( - "File deleted successfully: file_id={}, user_id={}", - file_id, - user_id - ); - - Ok(deleted) - } -} - -impl FileServiceImpl { - /// Delete file from OpenAI API - async fn delete_file_from_openai(&self, file_id: &str) -> Result { - let path = format!("files/{}", file_id); - - tracing::debug!("Deleting file from OpenAI: {}", path); - - let response = self - .openai_proxy - .forward_request(Method::DELETE, &path, http::HeaderMap::new(), None) - .await - .map_err(|e| FileError::ApiError(e.to_string()))?; - - if response.status != 200 { - tracing::error!( - "OpenAI API returned status {} for file deletion {}", - response.status, - file_id - ); - return Err(FileError::ApiError(format!( - "OpenAI API returned status {}", - response.status - ))); - } - - tracing::debug!("Successfully deleted file {} from OpenAI", file_id); - - // Collect the response body - let body_bytes: Bytes = response - .body - .try_collect::>() - .await - .map_err(|e| FileError::ApiError(format!("Failed to read response: {}", e)))? - .into_iter() - .flatten() - .collect(); - - // Parse as JSON - let file: serde_json::Value = serde_json::from_slice(&body_bytes) - .map_err(|e| FileError::ApiError(format!("Failed to parse JSON: {}", e)))?; - - Ok(file) - } -} diff --git a/crates/services/src/lib.rs b/crates/services/src/lib.rs index 46fe8868..d64454d9 100644 --- a/crates/services/src/lib.rs +++ b/crates/services/src/lib.rs @@ -4,9 +4,7 @@ pub mod analytics; pub mod auth; pub mod bi_metrics; pub mod consts; -pub mod conversation; pub mod db_pool; -pub mod file; pub mod metrics; pub mod model; pub mod response; From a622a8aeef1d258600808a24b83ff4f7275387db Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:11:22 +0800 Subject: [PATCH 3/4] docs: describe retired stateful API architecture --- CLAUDE.md | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8a2890ec..d8abaf96 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ tracing::debug!("Session validated: session_id={}, user_id={}", session_id, user ## Project Overview -NEAR AI Chat API - A Rust backend that proxies OpenAI API requests while tracking user conversations in PostgreSQL. Provides OAuth authentication (Google/GitHub), user session management, and a frontend served as static files. +NEAR AI Chat API - A Rust backend for authenticated, OpenAI-compatible Cloud API proxying, account and agent management, and a frontend served as static files. Conversations, Files, sharing, share groups, and shared-with-me are retired local surfaces: they return a stable `410 Gone` response rather than accessing PostgreSQL or an upstream stateful API. `/v1/responses` remains as a stateless proxy and always sends `store: false`. ## Build & Development Commands @@ -83,11 +83,10 @@ cargo run --bin api cargo test --features test # All tests with mock-login endpoint cargo test --test admin_tests --features test # Admin tests only -# E2E tests (make real OpenAI API calls, require valid credentials) -cargo test --test e2e_api_tests --features test -- --ignored --nocapture - -# Run a specific test -cargo test --test e2e_api_tests test_conversation_workflow --features test -- --ignored --nocapture +# Focused stateless-proxy and retired-route tests +cargo test --test responses_stateless_tests --features test +cargo test --test conversations_tests --features test +cargo test --test files_tests --features test ``` ## Docker Development @@ -104,7 +103,7 @@ docker compose up -d --build # Rebuild and start ``` crates/ ├── api/ # Axum HTTP server, routes, middleware, OpenAPI docs (utoipa) -├── services/ # Business logic: auth, conversation, response proxy, user management +├── services/ # Business logic: auth, response proxy, user/agent/subscription management ├── database/ # PostgreSQL (tokio-postgres, deadpool), migrations, repositories └── config/ # Environment-based configuration structs ``` @@ -113,20 +112,23 @@ crates/ - **Repository Pattern**: Database access through trait-based repositories (`PostgresUserRepository`, etc.) - **Service Layer**: Business logic in `services` crate, injected into `AppState` -- **OpenAI Proxy**: All `/v1/*` routes forward to OpenAI with auth; conversation endpoints (`/v1/conversations/*`) track IDs in PostgreSQL +- **Cloud API Proxy**: Supported proxy routes forward upstream after their configured authentication, subscription, and rate-limit middleware. `/v1/responses` is explicitly stateless: it rejects stateful features locally and forwards `store: false`. +- **Retired Stateful Surfaces**: Conversations, Files, sharing, share groups, and shared-with-me retain their historical authentication boundaries but return local `410 Gone` responses with `Cache-Control: no-store`; they do not call provider-backed services, repositories, or Cloud API endpoints. - **Patroni Support**: Optional cluster discovery for HA PostgreSQL via `DATABASE_PRIMARY_APP_ID` ### Request Flow -1. Request → Auth middleware (validates session token) → Route handler -2. Conversation operations → Forward to OpenAI → Parse response → Track in DB -3. Generic `/v1/{*path}` → Forward to OpenAI (pass-through) +1. Request → its configured authentication boundary (session, optional session, or dual auth) → route handler +2. Supported proxy route → subscription/rate-limit checks where configured → Cloud API → response and usage handling +3. `/v1/responses` → strict local stateless validation → force `store: false` → Cloud API → `Cache-Control: no-store` +4. Retired Conversation/File/sharing route → historical authentication boundary → local `410 Gone` with no database or upstream state access ### Database - Migrations: `crates/database/src/migrations/sql/V*.sql` (refinery) - Runs automatically on startup in `main.rs` - Default connection: `localhost:5432/chat_api` (see `config/src/lib.rs` for env vars) +- Retired Conversation/File/sharing tables remain as historical schema and for existing local account-deletion cleanup; they are no longer wired into `AppState` or request handlers. ## Environment Variables From 4b5d4be12220bde6a0588472facb1cf3f369176f Mon Sep 17 00:00:00 2001 From: Coffee <95295094+hanakannzashi@users.noreply.github.com> Date: Thu, 20 Aug 2026 15:12:47 +0800 Subject: [PATCH 4/4] docs: clarify retired API auth boundary --- CLAUDE.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d8abaf96..450c81b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,7 +67,7 @@ tracing::debug!("Session validated: session_id={}, user_id={}", session_id, user ## Project Overview -NEAR AI Chat API - A Rust backend for authenticated, OpenAI-compatible Cloud API proxying, account and agent management, and a frontend served as static files. Conversations, Files, sharing, share groups, and shared-with-me are retired local surfaces: they return a stable `410 Gone` response rather than accessing PostgreSQL or an upstream stateful API. `/v1/responses` remains as a stateless proxy and always sends `store: false`. +NEAR AI Chat API - A Rust backend for authenticated, OpenAI-compatible Cloud API proxying, account and agent management, and a frontend served as static files. Conversations, Files, sharing, share groups, and shared-with-me are retired local surfaces: after their historical session or optional-auth boundary, their handlers return a stable `410 Gone` response rather than accessing Private Chat repositories or an upstream stateful API. `/v1/responses` remains as a stateless proxy; valid JSON object requests are normalized with `store: false` before forwarding. ## Build & Development Commands @@ -112,16 +112,16 @@ crates/ - **Repository Pattern**: Database access through trait-based repositories (`PostgresUserRepository`, etc.) - **Service Layer**: Business logic in `services` crate, injected into `AppState` -- **Cloud API Proxy**: Supported proxy routes forward upstream after their configured authentication, subscription, and rate-limit middleware. `/v1/responses` is explicitly stateless: it rejects stateful features locally and forwards `store: false`. -- **Retired Stateful Surfaces**: Conversations, Files, sharing, share groups, and shared-with-me retain their historical authentication boundaries but return local `410 Gone` responses with `Cache-Control: no-store`; they do not call provider-backed services, repositories, or Cloud API endpoints. +- **Cloud API Proxy**: Supported proxy routes forward upstream after their configured authentication, subscription, and rate-limit middleware. `/v1/responses` is explicitly stateless: it rejects stateful features locally and normalizes valid JSON object requests with `store: false` before forwarding. +- **Retired Stateful Surfaces**: Conversations, Files, sharing, share groups, and shared-with-me retain their historical authentication boundaries but return local `410 Gone` responses with `Cache-Control: no-store`; their handlers do not call Private Chat repositories or upstream stateful API endpoints. - **Patroni Support**: Optional cluster discovery for HA PostgreSQL via `DATABASE_PRIMARY_APP_ID` ### Request Flow 1. Request → its configured authentication boundary (session, optional session, or dual auth) → route handler 2. Supported proxy route → subscription/rate-limit checks where configured → Cloud API → response and usage handling -3. `/v1/responses` → strict local stateless validation → force `store: false` → Cloud API → `Cache-Control: no-store` -4. Retired Conversation/File/sharing route → historical authentication boundary → local `410 Gone` with no database or upstream state access +3. Valid JSON object `/v1/responses` request → strict local stateless validation → normalize `store: false` → Cloud API → `Cache-Control: no-store` +4. Retired Conversation/File/sharing route → historical authentication boundary (which may inspect sessions) → local `410 Gone`; the handler makes no Private Chat repository or upstream state request ### Database