diff --git a/common/src/storage/types/conversation.rs b/common/src/storage/types/conversation.rs index 6b7c157..6b1ab56 100644 --- a/common/src/storage/types/conversation.rs +++ b/common/src/storage/types/conversation.rs @@ -1,3 +1,4 @@ +use surrealdb::opt::PatchOp; use uuid::Uuid; use crate::{error::AppError, storage::db::SurrealDbClient, stored_object}; @@ -46,6 +47,31 @@ impl Conversation { Ok((conversation, messages)) } + pub async fn patch_title( + id: &str, + user_id: &str, + new_title: &str, + db: &SurrealDbClient, + ) -> Result<(), AppError> { + // First verify ownership by getting conversation user_id + let conversation: Option = db.get_item(id).await?; + let conversation = + conversation.ok_or_else(|| AppError::NotFound("Conversation not found".to_string()))?; + + if conversation.user_id != user_id { + return Err(AppError::Auth( + "Unauthorized to update this conversation".to_string(), + )); + } + + let _updated: Option = db + .update((Self::table_name(), id)) + .patch(PatchOp::replace("/title", new_title.to_string())) + .patch(PatchOp::replace("/updated_at", Utc::now())) + .await?; + + Ok(()) + } } #[cfg(test)] @@ -141,6 +167,85 @@ mod tests { } } + #[tokio::test] + async fn test_patch_title_success() { + let namespace = "test_ns"; + let database = &Uuid::new_v4().to_string(); + let db = SurrealDbClient::memory(namespace, database) + .await + .expect("Failed to start in-memory surrealdb"); + + let user_id = "user_1"; + let original_title = "Original Title"; + let conversation = Conversation::new(user_id.to_string(), original_title.to_string()); + let conversation_id = conversation.id.clone(); + + db.store_item(conversation) + .await + .expect("Failed to store conversation"); + + let new_title = "Updated Title"; + + // Patch title successfully + let result = Conversation::patch_title(&conversation_id, user_id, new_title, &db).await; + assert!(result.is_ok()); + + // Retrieve from DB to verify + let updated_conversation = db + .get_item::(&conversation_id) + .await + .expect("Failed to get conversation") + .expect("Conversation missing"); + assert_eq!(updated_conversation.title, new_title); + assert_eq!(updated_conversation.user_id, user_id); + } + + #[tokio::test] + async fn test_patch_title_not_found() { + let namespace = "test_ns"; + let database = &Uuid::new_v4().to_string(); + let db = SurrealDbClient::memory(namespace, database) + .await + .expect("Failed to start in-memory surrealdb"); + + // Try to patch non-existing conversation + let result = Conversation::patch_title("nonexistent", "user_x", "New Title", &db).await; + + assert!(result.is_err()); + match result { + Err(AppError::NotFound(_)) => {} + _ => panic!("Expected NotFound error"), + } + } + + #[tokio::test] + async fn test_patch_title_unauthorized() { + let namespace = "test_ns"; + let database = &Uuid::new_v4().to_string(); + let db = SurrealDbClient::memory(namespace, database) + .await + .expect("Failed to start in-memory surrealdb"); + + let owner_id = "owner"; + let other_user_id = "intruder"; + let conversation = Conversation::new(owner_id.to_string(), "Private".to_string()); + let conversation_id = conversation.id.clone(); + + db.store_item(conversation) + .await + .expect("Failed to store conversation"); + + // Attempt patch with unauthorized user + let result = + Conversation::patch_title(&conversation_id, other_user_id, "Hacked Title", &db).await; + + assert!(result.is_err()); + match result { + Err(AppError::Auth(_)) => {} + _ => panic!("Expected Auth error"), + } + } + #[tokio::test] async fn test_get_complete_conversation_with_messages() { // Setup in-memory database for testing diff --git a/common/src/storage/types/file_info.rs b/common/src/storage/types/file_info.rs index 477e34d..7cdff9a 100644 --- a/common/src/storage/types/file_info.rs +++ b/common/src/storage/types/file_info.rs @@ -580,7 +580,7 @@ mod tests { // Should fail with FileNotFound error assert!(result.is_err()); match result { - Err(FileError::FileNotFound(_)) => { + Err(AppError::File(_)) => { // Expected error } _ => panic!("Expected FileNotFound error"), diff --git a/common/src/storage/types/ingestion_payload.rs b/common/src/storage/types/ingestion_payload.rs index 336adae..85757ce 100644 --- a/common/src/storage/types/ingestion_payload.rs +++ b/common/src/storage/types/ingestion_payload.rs @@ -96,6 +96,8 @@ impl IngestionPayload { #[cfg(test)] mod tests { + use chrono::Utc; + use super::*; // Create a mock FileInfo for testing diff --git a/common/src/storage/types/user.rs b/common/src/storage/types/user.rs index c6f3d6f..c938bf3 100644 --- a/common/src/storage/types/user.rs +++ b/common/src/storage/types/user.rs @@ -421,7 +421,10 @@ impl User { ) -> Result, AppError> { let conversations: Vec = db .client - .query("SELECT * FROM type::table($table_name) WHERE user_id = $user_id") + .query( + "SELECT * FROM type::table($table_name) WHERE user_id = $user_id + ORDER BY updated_at DESC", + ) .bind(("table_name", Conversation::table_name())) .bind(("user_id", user_id.to_string())) .await? @@ -775,4 +778,41 @@ mod tests { let updated_user = updated_user.unwrap(); assert_eq!(updated_user.timezone, new_timezone); } + + #[tokio::test] + async fn test_conversations_order() { + let db = setup_test_db().await; + let user_id = "user_order_test"; + + // Create conversations with varying updated_at timestamps + let mut conversations = Vec::new(); + for i in 0..5 { + let mut conv = Conversation::new(user_id.to_string(), format!("Conv {}", i)); + // Fake updated_at i minutes apart + conv.updated_at = chrono::Utc::now() - chrono::Duration::minutes(i); + db.store_item(conv.clone()) + .await + .expect("Failed to store conversation"); + conversations.push(conv); + } + + // Retrieve via get_user_conversations - should be ordered by updated_at DESC + let retrieved = User::get_user_conversations(user_id, &db) + .await + .expect("Failed to get conversations"); + + assert_eq!(retrieved.len(), conversations.len()); + + for window in retrieved.windows(2) { + // Assert each earlier conversation has updated_at >= later conversation + assert!( + window[0].updated_at >= window[1].updated_at, + "Conversations not ordered descending by updated_at" + ); + } + + // (Optional) Check first conversation title matches the most recently updated + let most_recent = conversations.iter().max_by_key(|c| c.updated_at).unwrap(); + assert_eq!(retrieved[0].id, most_recent.id); + } } diff --git a/html-router/src/routes/chat/chat_handlers.rs b/html-router/src/routes/chat/chat_handlers.rs index 071c955..69cf5ca 100644 --- a/html-router/src/routes/chat/chat_handlers.rs +++ b/html-router/src/routes/chat/chat_handlers.rs @@ -224,3 +224,110 @@ pub async fn new_chat_user_message( Ok(response.into_response()) } + +#[derive(Deserialize)] +pub struct PatchConversationTitle { + title: String, +} + +#[derive(Serialize)] +pub struct DrawerContext { + user: User, + conversation_archive: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + edit_conversation_id: Option, +} +pub async fn show_conversation_editing_title( + State(state): State, + RequireUser(user): RequireUser, + Path(conversation_id): Path, +) -> Result { + let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?; + + let owns = conversation_archive + .iter() + .any(|c| c.id == conversation_id && c.user_id == user.id); + if !owns { + return Ok(TemplateResponse::unauthorized().into_response()); + } + + Ok(TemplateResponse::new_template( + "chat/drawer.html", + DrawerContext { + user, + conversation_archive, + edit_conversation_id: Some(conversation_id), + }, + ) + .into_response()) +} + +pub async fn patch_conversation_title( + State(state): State, + RequireUser(user): RequireUser, + Path(conversation_id): Path, + Form(form): Form, +) -> Result { + Conversation::patch_title(&conversation_id, &user.id, &form.title, &state.db).await?; + + let updated_conversations = User::get_user_conversations(&user.id, &state.db).await?; + + Ok(TemplateResponse::new_template( + "chat/drawer.html", + DrawerContext { + user, + conversation_archive: updated_conversations, + edit_conversation_id: None, + }, + ) + .into_response()) +} + +pub async fn delete_conversation( + State(state): State, + RequireUser(user): RequireUser, + Path(conversation_id): Path, +) -> Result { + let conversation: Conversation = state + .db + .get_item(&conversation_id) + .await? + .ok_or_else(|| AppError::NotFound("Conversation not found".to_string()))?; + + if conversation.user_id != user.id { + return Ok(TemplateResponse::unauthorized().into_response()); + } + + state + .db + .delete_item::(&conversation_id) + .await?; + + let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?; + + Ok(TemplateResponse::new_template( + "chat/drawer.html", + DrawerContext { + user, + conversation_archive, + edit_conversation_id: None, + }, + ) + .into_response()) +} +pub async fn reload_sidebar( + State(state): State, + RequireUser(user): RequireUser, +) -> Result { + let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?; + + Ok(TemplateResponse::new_template( + "chat/drawer.html", + DrawerContext { + user, + conversation_archive, + edit_conversation_id: None, + }, + ) + .into_response()) +} diff --git a/html-router/src/routes/chat/mod.rs b/html-router/src/routes/chat/mod.rs index c272387..c86fd6a 100644 --- a/html-router/src/routes/chat/mod.rs +++ b/html-router/src/routes/chat/mod.rs @@ -8,7 +8,8 @@ use axum::{ Router, }; use chat_handlers::{ - new_chat_user_message, new_user_message, show_chat_base, show_existing_chat, + delete_conversation, new_chat_user_message, new_user_message, patch_conversation_title, + reload_sidebar, show_chat_base, show_conversation_editing_title, show_existing_chat, show_initialized_chat, }; use message_response_stream::get_response_stream; @@ -23,7 +24,17 @@ where { Router::new() .route("/chat", get(show_chat_base).post(new_chat_user_message)) - .route("/chat/:id", get(show_existing_chat).post(new_user_message)) + .route( + "/chat/:id", + get(show_existing_chat) + .post(new_user_message) + .delete(delete_conversation), + ) + .route( + "/chat/:id/title", + get(show_conversation_editing_title).patch(patch_conversation_title), + ) + .route("/chat/sidebar", get(reload_sidebar)) .route("/initialized-chat", post(show_initialized_chat)) .route("/chat/response-stream", get(get_response_stream)) .route("/chat/reference/:id", get(show_reference_tooltip)) diff --git a/html-router/templates/chat/drawer.html b/html-router/templates/chat/drawer.html index 6388701..185a90b 100644 --- a/html-router/templates/chat/drawer.html +++ b/html-router/templates/chat/drawer.html @@ -1,15 +1,44 @@
-
\ No newline at end of file diff --git a/html-router/templates/icons/check_icon.html b/html-router/templates/icons/check_icon.html new file mode 100644 index 0000000..eb8ea43 --- /dev/null +++ b/html-router/templates/icons/check_icon.html @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/html-router/templates/icons/x_icon.html b/html-router/templates/icons/x_icon.html new file mode 100644 index 0000000..e801c12 --- /dev/null +++ b/html-router/templates/icons/x_icon.html @@ -0,0 +1,4 @@ + + + \ No newline at end of file