diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs index 572ff31..9e15b48 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -47,45 +47,26 @@ pub async fn list_channel_loose_props<'e>( /// Deletes from inventory and inserts into loose_props with 30-minute expiry. /// Returns the created loose prop. /// Returns an error if the prop is non-droppable (essential prop). -pub async fn drop_prop_to_canvas( - pool: &sqlx::PgPool, +pub async fn drop_prop_to_canvas<'e>( + executor: impl PgExecutor<'e>, inventory_item_id: Uuid, user_id: Uuid, channel_id: Uuid, position_x: f64, position_y: f64, ) -> Result { - // First check if the item exists and is droppable - let item_check: Option<(bool,)> = sqlx::query_as( - r#"SELECT is_droppable FROM props.inventory WHERE id = $1 AND user_id = $2"#, - ) - .bind(inventory_item_id) - .bind(user_id) - .fetch_optional(pool) - .await?; - - match item_check { - None => { - return Err(AppError::NotFound( - "Inventory item not found or not owned by user".to_string(), - )); - } - Some((false,)) => { - return Err(AppError::Forbidden( - "This prop cannot be dropped - it is an essential prop".to_string(), - )); - } - Some((true,)) => { - // Item is droppable, proceed with the drop operation - } - } - - // Use a CTE to delete from inventory and insert to loose_props in one query - let prop = sqlx::query_as::<_, LooseProp>( + // Single CTE that checks existence/droppability and performs the operation atomically. + // Returns status flags plus the LooseProp data (if successful). + let result: Option<(bool, bool, bool, Option, Option, Option, Option, Option, Option, Option, Option>, Option>, Option, Option)> = sqlx::query_as( r#" - WITH deleted_item AS ( - DELETE FROM props.inventory + WITH item_info AS ( + SELECT id, is_droppable, server_prop_id, realm_prop_id, prop_name, prop_asset_path + FROM props.inventory WHERE id = $1 AND user_id = $2 + ), + deleted_item AS ( + DELETE FROM props.inventory + WHERE id = $1 AND user_id = $2 AND is_droppable = true RETURNING id, server_prop_id, realm_prop_id, prop_name, prop_asset_path ), inserted_prop AS ( @@ -117,6 +98,9 @@ pub async fn drop_prop_to_canvas( created_at ) SELECT + EXISTS(SELECT 1 FROM item_info) AS item_existed, + COALESCE((SELECT is_droppable FROM item_info), false) AS was_droppable, + EXISTS(SELECT 1 FROM deleted_item) AS was_deleted, ip.id, ip.channel_id, ip.server_prop_id, @@ -128,8 +112,9 @@ pub async fn drop_prop_to_canvas( ip.created_at, di.prop_name, di.prop_asset_path - FROM inserted_prop ip - CROSS JOIN deleted_item di + FROM (SELECT 1) AS dummy + LEFT JOIN inserted_prop ip ON true + LEFT JOIN deleted_item di ON true "#, ) .bind(inventory_item_id) @@ -137,13 +122,72 @@ pub async fn drop_prop_to_canvas( .bind(channel_id) .bind(position_x as f32) .bind(position_y as f32) - .fetch_optional(pool) - .await? - .ok_or_else(|| { - AppError::Internal("Unexpected error dropping prop to canvas".to_string()) - })?; + .fetch_optional(executor) + .await?; - Ok(prop) + match result { + None => { + // Query returned no rows (shouldn't happen with our dummy table) + Err(AppError::Internal( + "Unexpected error dropping prop to canvas".to_string(), + )) + } + Some((false, _, _, _, _, _, _, _, _, _, _, _, _, _)) => { + // Item didn't exist + Err(AppError::NotFound( + "Inventory item not found or not owned by user".to_string(), + )) + } + Some((true, false, _, _, _, _, _, _, _, _, _, _, _, _)) => { + // Item existed but is not droppable + Err(AppError::Forbidden( + "This prop cannot be dropped - it is an essential prop".to_string(), + )) + } + Some((true, true, false, _, _, _, _, _, _, _, _, _, _, _)) => { + // Item was droppable but delete failed (shouldn't happen) + Err(AppError::Internal( + "Unexpected error dropping prop to canvas".to_string(), + )) + } + Some(( + true, + true, + true, + Some(id), + Some(channel_id), + server_prop_id, + realm_prop_id, + Some(position_x), + Some(position_y), + dropped_by, + Some(expires_at), + Some(created_at), + Some(prop_name), + Some(prop_asset_path), + )) => { + // Success! Convert f32 positions to f64. + Ok(LooseProp { + id, + channel_id, + server_prop_id, + realm_prop_id, + position_x: position_x.into(), + position_y: position_y.into(), + dropped_by, + expires_at: Some(expires_at), + created_at, + prop_name, + prop_asset_path, + }) + } + _ => { + // Some fields were unexpectedly null + Err(AppError::Internal( + "Unexpected null values in drop prop result".to_string(), + )) + } + } } /// Pick up a loose prop (delete from loose_props, insert to inventory). diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 41610b0..ee3ba15 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -281,13 +281,9 @@ async fn handle_socket( } // Drop the setup connection - we'll use recv_conn for the receive task - // and pool for cleanup (which will use the same RLS context issue, but leave_channel - // needs user_id match anyway) + // and pool for cleanup (leave_channel needs user_id match anyway) drop(conn); - // Clone pool for use in the receive task (needed for multi-query operations) - let recv_pool = pool.clone(); - // Spawn task to handle incoming messages from client let recv_task = tokio::spawn(async move { while let Some(Ok(msg)) = receiver.next().await { @@ -404,7 +400,7 @@ async fn handle_socket( let pos_y = member.position_y + offset_y; match loose_props::drop_prop_to_canvas( - &recv_pool, + &mut *recv_conn, inventory_item_id, user_id, channel_id, @@ -426,6 +422,16 @@ async fn handle_socket( } Err(e) => { tracing::error!("[WS] Drop prop failed: {:?}", e); + let (code, message) = match &e { + chattyness_error::AppError::Forbidden(msg) => { + ("PROP_NOT_DROPPABLE".to_string(), msg.clone()) + } + chattyness_error::AppError::NotFound(msg) => { + ("PROP_NOT_FOUND".to_string(), msg.clone()) + } + _ => ("DROP_FAILED".to_string(), format!("{:?}", e)), + }; + let _ = tx.send(ServerMessage::Error { code, message }); } } } @@ -453,6 +459,10 @@ async fn handle_socket( } Err(e) => { tracing::error!("[WS] Pick up prop failed: {:?}", e); + let _ = tx.send(ServerMessage::Error { + code: "PICKUP_FAILED".to_string(), + message: format!("{:?}", e), + }); } } } diff --git a/crates/chattyness-user-ui/src/components/inventory.rs b/crates/chattyness-user-ui/src/components/inventory.rs index 8c48ef1..b06c22a 100644 --- a/crates/chattyness-user-ui/src/components/inventory.rs +++ b/crates/chattyness-user-ui/src/components/inventory.rs @@ -251,6 +251,7 @@ pub fn InventoryPopup( let item = items.get().into_iter().find(|i| i.id == item_id)?; let handle_drop = handle_drop.clone(); let is_dropping = dropping.get(); + let is_droppable = item.is_droppable; Some(view! {
@@ -260,15 +261,25 @@ pub fn InventoryPopup(

{if item.is_transferable { "Transferable" } else { "Not transferable" }} {if item.is_portable { " \u{2022} Portable" } else { "" }} + {if is_droppable { " \u{2022} Droppable" } else { " \u{2022} Essential" }}

- // Drop button + // Drop button - disabled for non-droppable (essential) props diff --git a/crates/chattyness-user-ui/src/components/ws_client.rs b/crates/chattyness-user-ui/src/components/ws_client.rs index dab42f5..58e2108 100644 --- a/crates/chattyness-user-ui/src/components/ws_client.rs +++ b/crates/chattyness-user-ui/src/components/ws_client.rs @@ -264,7 +264,7 @@ fn handle_server_message( // Heartbeat acknowledged - nothing to do } ServerMessage::Error { code, message } => { - #[cfg(debug_assertions)] + // Always log errors to console (not just debug mode) web_sys::console::error_1(&format!("[WS] Server error: {} - {}", code, message).into()); } ServerMessage::ChatMessageReceived {