//! Loose props database queries. //! //! Handles props dropped in channels that can be picked up by users. use sqlx::PgExecutor; use uuid::Uuid; use crate::models::{InventoryItem, LooseProp}; use chattyness_error::AppError; /// List all loose props in a channel (excluding expired). pub async fn list_channel_loose_props<'e>( executor: impl PgExecutor<'e>, channel_id: Uuid, ) -> Result, AppError> { let props = sqlx::query_as::<_, LooseProp>( r#" SELECT lp.id, lp.instance_id as channel_id, lp.server_prop_id, lp.realm_prop_id, ST_X(lp.position) as position_x, ST_Y(lp.position) as position_y, lp.dropped_by, lp.expires_at, lp.created_at, COALESCE(sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path FROM scene.loose_props lp LEFT JOIN server.props sp ON lp.server_prop_id = sp.id LEFT JOIN realm.props rp ON lp.realm_prop_id = rp.id WHERE lp.instance_id = $1 AND (lp.expires_at IS NULL OR lp.expires_at > now()) ORDER BY lp.created_at ASC "#, ) .bind(channel_id) .fetch_all(executor) .await?; Ok(props) } /// Drop a prop from inventory to the canvas. /// /// 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<'e>( executor: impl PgExecutor<'e>, inventory_item_id: Uuid, user_id: Uuid, channel_id: Uuid, position_x: f64, position_y: f64, ) -> Result { // 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 item_info AS ( SELECT id, is_droppable, server_prop_id, realm_prop_id, prop_name, prop_asset_path FROM auth.inventory WHERE id = $1 AND user_id = $2 ), deleted_item AS ( DELETE FROM auth.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 ( INSERT INTO scene.loose_props ( instance_id, server_prop_id, realm_prop_id, position, dropped_by, expires_at ) SELECT $3, di.server_prop_id, di.realm_prop_id, public.make_virtual_point($4::real, $5::real), $2, now() + interval '30 minutes' FROM deleted_item di RETURNING id, instance_id as channel_id, server_prop_id, realm_prop_id, ST_X(position) as position_x, ST_Y(position) as position_y, dropped_by, expires_at, 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, ip.realm_prop_id, ip.position_x, ip.position_y, ip.dropped_by, ip.expires_at, ip.created_at, di.prop_name, di.prop_asset_path FROM (SELECT 1) AS dummy LEFT JOIN inserted_prop ip ON true LEFT JOIN deleted_item di ON true "#, ) .bind(inventory_item_id) .bind(user_id) .bind(channel_id) .bind(position_x as f32) .bind(position_y as f32) .fetch_optional(executor) .await?; 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). /// /// Returns the created inventory item. pub async fn pick_up_loose_prop<'e>( executor: impl PgExecutor<'e>, loose_prop_id: Uuid, user_id: Uuid, ) -> Result { // Use a CTE to delete from loose_props and insert to inventory let item = sqlx::query_as::<_, InventoryItem>( r#" WITH deleted_prop AS ( DELETE FROM scene.loose_props WHERE id = $1 AND (expires_at IS NULL OR expires_at > now()) RETURNING id, server_prop_id, realm_prop_id ), source_info AS ( SELECT COALESCE(sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, 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 LEFT JOIN server.props sp ON dp.server_prop_id = sp.id LEFT JOIN realm.props rp ON dp.realm_prop_id = rp.id ), inserted_item AS ( INSERT INTO auth.inventory ( user_id, server_prop_id, realm_prop_id, prop_name, prop_asset_path, layer, origin, is_transferable, is_portable, is_droppable, provenance, acquired_at ) SELECT $2, si.server_prop_id, si.realm_prop_id, si.prop_name, si.prop_asset_path, si.layer, 'server_library'::server.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, is_droppable, acquired_at ) SELECT ii.id, ii.prop_name, ii.prop_asset_path, ii.layer, ii.is_transferable, ii.is_portable, ii.is_droppable, 'server_library'::server.prop_origin as origin, ii.acquired_at FROM inserted_item ii "#, ) .bind(loose_prop_id) .bind(user_id) .fetch_optional(executor) .await? .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; Ok(item) } /// Delete expired loose props. /// /// Returns the number of props deleted. pub async fn cleanup_expired_props<'e>(executor: impl PgExecutor<'e>) -> Result { let result = sqlx::query( r#" DELETE FROM scene.loose_props WHERE expires_at IS NOT NULL AND expires_at <= now() "#, ) .execute(executor) .await?; Ok(result.rows_affected()) }