diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index eb6ccf1..fa82ce9 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -614,6 +614,7 @@ pub struct InventoryItem { pub layer: Option, pub is_transferable: bool, pub is_portable: bool, + pub is_droppable: bool, pub origin: PropOrigin, pub acquired_at: DateTime, } @@ -663,6 +664,7 @@ pub struct ServerProp { pub is_unique: bool, pub is_transferable: bool, pub is_portable: bool, + pub is_droppable: bool, pub is_active: bool, pub available_from: Option>, pub available_until: Option>, diff --git a/crates/chattyness-db/src/queries/inventory.rs b/crates/chattyness-db/src/queries/inventory.rs index 35bb45f..b23cb25 100644 --- a/crates/chattyness-db/src/queries/inventory.rs +++ b/crates/chattyness-db/src/queries/inventory.rs @@ -20,6 +20,7 @@ pub async fn list_user_inventory<'e>( layer, is_transferable, is_portable, + is_droppable, origin, acquired_at FROM props.inventory @@ -35,16 +36,30 @@ pub async fn list_user_inventory<'e>( } /// Drop a prop (remove from inventory). +/// Returns an error if the prop is non-droppable (essential prop). pub async fn drop_inventory_item<'e>( executor: impl PgExecutor<'e>, user_id: Uuid, item_id: Uuid, ) -> Result<(), AppError> { - let result = sqlx::query( + // Use a CTE to check existence/droppability and delete in a single query + // Returns: (existed, was_droppable, was_deleted) + let result: Option<(bool, bool, bool)> = sqlx::query_as( r#" - DELETE FROM props.inventory - WHERE id = $1 AND user_id = $2 - RETURNING id + WITH item_info AS ( + SELECT id, is_droppable + FROM props.inventory + WHERE id = $1 AND user_id = $2 + ), + deleted AS ( + DELETE FROM props.inventory + WHERE id = $1 AND user_id = $2 AND is_droppable = true + RETURNING id + ) + SELECT + EXISTS(SELECT 1 FROM item_info) AS existed, + COALESCE((SELECT is_droppable FROM item_info), false) AS was_droppable, + EXISTS(SELECT 1 FROM deleted) AS was_deleted "#, ) .bind(item_id) @@ -52,10 +67,26 @@ pub async fn drop_inventory_item<'e>( .fetch_optional(executor) .await?; - if result.is_none() { - return Err(AppError::NotFound( - "Inventory item not found or not owned by user".to_string(), - )); + match result { + Some((false, _, _)) | None => { + return Err(AppError::NotFound( + "Inventory item not found or not owned by user".to_string(), + )); + } + Some((true, false, _)) => { + return Err(AppError::Forbidden( + "This prop cannot be dropped - it is an essential prop".to_string(), + )); + } + Some((true, true, true)) => { + // Successfully deleted + } + Some((true, true, false)) => { + // Should not happen - item existed, was droppable, but wasn't deleted + return Err(AppError::Internal( + "Unexpected error dropping inventory item".to_string(), + )); + } } Ok(()) diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs index d92893e..572ff31 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -46,14 +46,40 @@ 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. -pub async fn drop_prop_to_canvas<'e>( - executor: impl PgExecutor<'e>, +/// Returns an error if the prop is non-droppable (essential prop). +pub async fn drop_prop_to_canvas( + pool: &sqlx::PgPool, 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>( r#" @@ -111,10 +137,10 @@ pub async fn drop_prop_to_canvas<'e>( .bind(channel_id) .bind(position_x as f32) .bind(position_y as f32) - .fetch_optional(executor) + .fetch_optional(pool) .await? .ok_or_else(|| { - AppError::NotFound("Inventory item not found or not owned by user".to_string()) + AppError::Internal("Unexpected error dropping prop to canvas".to_string()) })?; Ok(prop) @@ -144,6 +170,7 @@ pub async fn pick_up_loose_prop<'e>( COALESCE(sp.default_layer, rp.default_layer) as layer, COALESCE(sp.is_transferable, rp.is_transferable) as is_transferable, COALESCE(sp.is_portable, true) as is_portable, + COALESCE(sp.is_droppable, rp.is_droppable, true) as is_droppable, dp.server_prop_id, dp.realm_prop_id FROM deleted_prop dp @@ -161,6 +188,7 @@ pub async fn pick_up_loose_prop<'e>( origin, is_transferable, is_portable, + is_droppable, provenance, acquired_at ) @@ -174,10 +202,11 @@ pub async fn pick_up_loose_prop<'e>( 'server_library'::props.prop_origin, COALESCE(si.is_transferable, true), COALESCE(si.is_portable, true), + COALESCE(si.is_droppable, true), '[]'::jsonb, now() FROM source_info si - RETURNING id, prop_name, prop_asset_path, layer, is_transferable, is_portable, acquired_at + RETURNING id, prop_name, prop_asset_path, layer, is_transferable, is_portable, is_droppable, acquired_at ) SELECT ii.id, @@ -186,6 +215,7 @@ pub async fn pick_up_loose_prop<'e>( ii.layer, ii.is_transferable, ii.is_portable, + ii.is_droppable, 'server_library'::props.prop_origin as origin, ii.acquired_at FROM inserted_item ii diff --git a/crates/chattyness-db/src/queries/props.rs b/crates/chattyness-db/src/queries/props.rs index 04d8fa4..3382382 100644 --- a/crates/chattyness-db/src/queries/props.rs +++ b/crates/chattyness-db/src/queries/props.rs @@ -51,6 +51,7 @@ pub async fn get_server_prop_by_id<'e>( is_unique, is_transferable, is_portable, + is_droppable, is_active, available_from, available_until, @@ -139,6 +140,7 @@ pub async fn create_server_prop<'e>( is_unique, is_transferable, is_portable, + is_droppable, is_active, available_from, available_until, diff --git a/crates/chattyness-user-ui/src/api/websocket.rs b/crates/chattyness-user-ui/src/api/websocket.rs index 79a7266..41610b0 100644 --- a/crates/chattyness-user-ui/src/api/websocket.rs +++ b/crates/chattyness-user-ui/src/api/websocket.rs @@ -285,6 +285,9 @@ async fn handle_socket( // 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 { @@ -401,7 +404,7 @@ async fn handle_socket( let pos_y = member.position_y + offset_y; match loose_props::drop_prop_to_canvas( - &mut *recv_conn, + &recv_pool, inventory_item_id, user_id, channel_id,