//! 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; /// Ensure an instance exists for a scene. /// /// In this system, scenes are used directly as instances (channel_id = scene_id). /// This creates an instance record if one doesn't exist, using the scene_id as the instance_id. /// This is needed for loose_props foreign key constraint. pub async fn ensure_scene_instance<'e>( executor: impl PgExecutor<'e>, scene_id: Uuid, ) -> Result<(), AppError> { sqlx::query( r#" INSERT INTO scene.instances (id, scene_id, instance_type) SELECT $1, $1, 'public'::scene.instance_type WHERE EXISTS (SELECT 1 FROM realm.scenes WHERE id = $1) ON CONFLICT (id) DO NOTHING "#, ) .bind(scene_id) .execute(executor) .await?; Ok(()) } /// 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.scale, 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, lp.is_locked, lp.locked_by 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). // Includes scale inherited from the source prop's default_scale. let result: Option<( bool, bool, bool, Option, Option, Option, Option, Option, Option, Option, Option, Option>, Option>, Option, Option, )> = sqlx::query_as( r#" WITH item_info AS ( SELECT inv.id, inv.is_droppable, inv.server_prop_id, inv.realm_prop_id, inv.prop_name, inv.prop_asset_path, COALESCE(sp.default_scale, rp.default_scale, 1.0) as default_scale FROM auth.inventory inv LEFT JOIN server.props sp ON inv.server_prop_id = sp.id LEFT JOIN realm.props rp ON inv.realm_prop_id = rp.id WHERE inv.id = $1 AND inv.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, scale, dropped_by, expires_at ) SELECT $3, di.server_prop_id, di.realm_prop_id, public.make_virtual_point($4::real, $5::real), (SELECT default_scale FROM item_info), $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)::real as position_x, ST_Y(position)::real as position_y, scale, 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.scale, 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), Some(scale), 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(), scale, dropped_by, expires_at: Some(expires_at), created_at, prop_name, prop_asset_path, is_locked: false, locked_by: None, }) } _ => { // 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) } /// Update the scale of a loose prop. /// /// Server admins can update any loose prop. /// Realm admins can update loose props in their realm. pub async fn update_loose_prop_scale<'e>( executor: impl PgExecutor<'e>, loose_prop_id: Uuid, scale: f32, ) -> Result { // Validate scale range if !(0.1..=10.0).contains(&scale) { return Err(AppError::Validation( "Scale must be between 0.1 and 10.0".to_string(), )); } let prop = sqlx::query_as::<_, LooseProp>( r#" WITH updated AS ( UPDATE scene.loose_props SET scale = $2 WHERE id = $1 AND (expires_at IS NULL OR expires_at > now()) 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, scale, dropped_by, expires_at, created_at, is_locked, locked_by ) SELECT u.id, u.channel_id, u.server_prop_id, u.realm_prop_id, u.position_x, u.position_y, u.scale, u.dropped_by, u.expires_at, u.created_at, COALESCE(sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, u.is_locked, u.locked_by FROM updated u LEFT JOIN server.props sp ON u.server_prop_id = sp.id LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id "#, ) .bind(loose_prop_id) .bind(scale) .fetch_optional(executor) .await? .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; Ok(prop) } /// Get a loose prop by ID. pub async fn get_loose_prop_by_id<'e>( executor: impl PgExecutor<'e>, loose_prop_id: Uuid, ) -> Result, AppError> { let prop = 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.scale, 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, lp.is_locked, lp.locked_by 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.id = $1 AND (lp.expires_at IS NULL OR lp.expires_at > now()) "#, ) .bind(loose_prop_id) .fetch_optional(executor) .await?; Ok(prop) } /// Move a loose prop to a new position. pub async fn move_loose_prop<'e>( executor: impl PgExecutor<'e>, loose_prop_id: Uuid, x: f64, y: f64, ) -> Result { let prop = sqlx::query_as::<_, LooseProp>( r#" WITH updated AS ( UPDATE scene.loose_props SET position = public.make_virtual_point($2::real, $3::real) WHERE id = $1 AND (expires_at IS NULL OR expires_at > now()) 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, scale, dropped_by, expires_at, created_at, is_locked, locked_by ) SELECT u.id, u.channel_id, u.server_prop_id, u.realm_prop_id, u.position_x, u.position_y, u.scale, u.dropped_by, u.expires_at, u.created_at, COALESCE(sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, u.is_locked, u.locked_by FROM updated u LEFT JOIN server.props sp ON u.server_prop_id = sp.id LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id "#, ) .bind(loose_prop_id) .bind(x as f32) .bind(y as f32) .fetch_optional(executor) .await? .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; Ok(prop) } /// Lock a loose prop (moderator only). pub async fn lock_loose_prop<'e>( executor: impl PgExecutor<'e>, loose_prop_id: Uuid, locked_by: Uuid, ) -> Result { let prop = sqlx::query_as::<_, LooseProp>( r#" WITH updated AS ( UPDATE scene.loose_props SET is_locked = true, locked_by = $2 WHERE id = $1 AND (expires_at IS NULL OR expires_at > now()) 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, scale, dropped_by, expires_at, created_at, is_locked, locked_by ) SELECT u.id, u.channel_id, u.server_prop_id, u.realm_prop_id, u.position_x, u.position_y, u.scale, u.dropped_by, u.expires_at, u.created_at, COALESCE(sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, u.is_locked, u.locked_by FROM updated u LEFT JOIN server.props sp ON u.server_prop_id = sp.id LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id "#, ) .bind(loose_prop_id) .bind(locked_by) .fetch_optional(executor) .await? .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; Ok(prop) } /// Unlock a loose prop (moderator only). pub async fn unlock_loose_prop<'e>( executor: impl PgExecutor<'e>, loose_prop_id: Uuid, ) -> Result { let prop = sqlx::query_as::<_, LooseProp>( r#" WITH updated AS ( UPDATE scene.loose_props SET is_locked = false, locked_by = NULL WHERE id = $1 AND (expires_at IS NULL OR expires_at > now()) 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, scale, dropped_by, expires_at, created_at, is_locked, locked_by ) SELECT u.id, u.channel_id, u.server_prop_id, u.realm_prop_id, u.position_x, u.position_y, u.scale, u.dropped_by, u.expires_at, u.created_at, COALESCE(sp.name, rp.name) as prop_name, COALESCE(sp.asset_path, rp.asset_path) as prop_asset_path, u.is_locked, u.locked_by FROM updated u LEFT JOIN server.props sp ON u.server_prop_id = sp.id LEFT JOIN realm.props rp ON u.realm_prop_id = rp.id "#, ) .bind(loose_prop_id) .fetch_optional(executor) .await? .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; Ok(prop) } /// 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()) }