From 4f0f88504ae070c539ca06645bddc647e5e72ac11b63bfb59206b78487b26813 Mon Sep 17 00:00:00 2001 From: Evan Carroll Date: Sat, 24 Jan 2026 01:42:52 -0600 Subject: [PATCH] cleanup: make the modeling of props better --- crates/chattyness-db/src/models.rs | 118 +++++++++++++++++- .../chattyness-db/src/queries/loose_props.rs | 40 +++--- 2 files changed, 139 insertions(+), 19 deletions(-) diff --git a/crates/chattyness-db/src/models.rs b/crates/chattyness-db/src/models.rs index f4eed0e..ee1688e 100644 --- a/crates/chattyness-db/src/models.rs +++ b/crates/chattyness-db/src/models.rs @@ -822,6 +822,50 @@ impl std::fmt::Display for PropOrigin { } } +/// Source of a prop, combining origin type and ID. +/// +/// This replaces the pattern of two nullable UUID columns (server_prop_id, realm_prop_id) +/// with a single enum that encodes both the source type and ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PropSource { + /// Prop from the server-wide library (server.props) + Server(Uuid), + /// Prop from a realm-specific library (realm.props) + Realm(Uuid), + /// Prop uploaded by a user (auth.uploads) - future use + Upload(Uuid), +} + +impl PropSource { + /// Extract the UUID from any variant. + pub fn id(&self) -> Uuid { + match self { + PropSource::Server(id) | PropSource::Realm(id) | PropSource::Upload(id) => *id, + } + } +} + +impl std::fmt::Display for PropSource { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PropSource::Server(id) => write!(f, "server:{}", id), + PropSource::Realm(id) => write!(f, "realm:{}", id), + PropSource::Upload(id) => write!(f, "upload:{}", id), + } + } +} + +impl From<&PropSource> for PropOrigin { + fn from(source: &PropSource) -> Self { + match source { + PropSource::Server(_) => PropOrigin::ServerLibrary, + PropSource::Realm(_) => PropOrigin::RealmLibrary, + PropSource::Upload(_) => PropOrigin::UserUpload, + } + } +} + /// An inventory item (user-owned prop). #[derive(Debug, Clone, Serialize, Deserialize)] #[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] @@ -840,6 +884,19 @@ pub struct InventoryItem { pub acquired_at: DateTime, } +impl InventoryItem { + /// Get the prop source combining `prop_id` and `origin` into a `PropSource`. + /// + /// Returns `None` if `prop_id` is `None` (shouldn't happen in practice). + pub fn prop_source(&self) -> Option { + self.prop_id.map(|id| match self.origin { + PropOrigin::ServerLibrary => PropSource::Server(id), + PropOrigin::RealmLibrary => PropSource::Realm(id), + PropOrigin::UserUpload => PropSource::Upload(id), + }) + } +} + /// Response for inventory list. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InventoryResponse { @@ -882,16 +939,69 @@ pub struct PropAcquisitionListResponse { pub props: Vec, } -/// A prop dropped in a channel, available for pickup. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[cfg_attr(feature = "ssr", derive(sqlx::FromRow))] -pub struct LooseProp { +/// Intermediate row type for database queries returning loose props. +/// +/// This maps directly to database columns (with nullable server_prop_id/realm_prop_id) +/// and is converted to `LooseProp` with a `PropSource` enum. +#[cfg(feature = "ssr")] +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct LoosePropRow { pub id: Uuid, pub channel_id: Uuid, pub server_prop_id: Option, pub realm_prop_id: Option, pub position_x: f64, pub position_y: f64, + pub scale: f32, + pub dropped_by: Option, + pub expires_at: Option>, + pub created_at: DateTime, + pub prop_name: String, + pub prop_asset_path: String, + pub is_locked: bool, + pub locked_by: Option, +} + +#[cfg(feature = "ssr")] +impl From for LooseProp { + fn from(row: LoosePropRow) -> Self { + let source = if let Some(id) = row.server_prop_id { + PropSource::Server(id) + } else if let Some(id) = row.realm_prop_id { + PropSource::Realm(id) + } else { + // Database CHECK constraint ensures exactly one is set, + // but we need a fallback. This should never happen. + panic!("LoosePropRow has neither server_prop_id nor realm_prop_id set") + }; + + LooseProp { + id: row.id, + channel_id: row.channel_id, + source, + position_x: row.position_x, + position_y: row.position_y, + scale: row.scale, + dropped_by: row.dropped_by, + expires_at: row.expires_at, + created_at: row.created_at, + prop_name: row.prop_name, + prop_asset_path: row.prop_asset_path, + is_locked: row.is_locked, + locked_by: row.locked_by, + } + } +} + +/// A prop dropped in a channel, available for pickup. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LooseProp { + pub id: Uuid, + pub channel_id: Uuid, + /// The source of this prop (server library, realm library, or user upload). + pub source: PropSource, + pub position_x: f64, + pub position_y: f64, /// Scale factor (0.1 - 10.0) inherited from prop definition at drop time. pub scale: f32, pub dropped_by: Option, diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs index b103937..0d94981 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -5,7 +5,7 @@ use sqlx::PgExecutor; use uuid::Uuid; -use crate::models::{InventoryItem, LooseProp}; +use crate::models::{InventoryItem, LooseProp, LoosePropRow, PropSource}; use chattyness_error::{AppError, OptionExt}; /// Ensure an instance exists for a scene. @@ -37,7 +37,7 @@ pub async fn list_channel_loose_props<'e>( executor: impl PgExecutor<'e>, channel_id: Uuid, ) -> Result, AppError> { - let props = sqlx::query_as::<_, LooseProp>( + let rows = sqlx::query_as::<_, LoosePropRow>( r#" SELECT lp.id, @@ -66,7 +66,7 @@ pub async fn list_channel_loose_props<'e>( .fetch_all(executor) .await?; - Ok(props) + Ok(rows.into_iter().map(LooseProp::from).collect()) } /// Drop a prop from inventory to the canvas. @@ -224,12 +224,22 @@ pub async fn drop_prop_to_canvas<'e>( Some(prop_name), Some(prop_asset_path), )) => { + // Construct PropSource from the nullable columns + let source = if let Some(sid) = server_prop_id { + PropSource::Server(sid) + } else if let Some(rid) = realm_prop_id { + PropSource::Realm(rid) + } else { + return Err(AppError::Internal( + "Dropped prop has neither server_prop_id nor realm_prop_id".to_string(), + )); + }; + // Success! Convert f32 positions to f64. Ok(LooseProp { id, channel_id, - server_prop_id, - realm_prop_id, + source, position_x: position_x.into(), position_y: position_y.into(), scale, @@ -352,7 +362,7 @@ pub async fn update_loose_prop_scale<'e>( )); } - let prop = sqlx::query_as::<_, LooseProp>( + let row = sqlx::query_as::<_, LoosePropRow>( r#" WITH updated AS ( UPDATE scene.loose_props @@ -399,7 +409,7 @@ pub async fn update_loose_prop_scale<'e>( .await? .or_not_found("Loose prop (may have expired)")?; - Ok(prop) + Ok(LooseProp::from(row)) } /// Get a loose prop by ID. @@ -407,7 +417,7 @@ 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>( + let row = sqlx::query_as::<_, LoosePropRow>( r#" SELECT lp.id, @@ -435,7 +445,7 @@ pub async fn get_loose_prop_by_id<'e>( .fetch_optional(executor) .await?; - Ok(prop) + Ok(row.map(LooseProp::from)) } /// Move a loose prop to a new position. @@ -445,7 +455,7 @@ pub async fn move_loose_prop<'e>( x: f64, y: f64, ) -> Result { - let prop = sqlx::query_as::<_, LooseProp>( + let row = sqlx::query_as::<_, LoosePropRow>( r#" WITH updated AS ( UPDATE scene.loose_props @@ -493,7 +503,7 @@ pub async fn move_loose_prop<'e>( .await? .or_not_found("Loose prop (may have expired)")?; - Ok(prop) + Ok(LooseProp::from(row)) } /// Lock a loose prop (moderator only). @@ -502,7 +512,7 @@ pub async fn lock_loose_prop<'e>( loose_prop_id: Uuid, locked_by: Uuid, ) -> Result { - let prop = sqlx::query_as::<_, LooseProp>( + let row = sqlx::query_as::<_, LoosePropRow>( r#" WITH updated AS ( UPDATE scene.loose_props @@ -549,7 +559,7 @@ pub async fn lock_loose_prop<'e>( .await? .or_not_found("Loose prop (may have expired)")?; - Ok(prop) + Ok(LooseProp::from(row)) } /// Unlock a loose prop (moderator only). @@ -557,7 +567,7 @@ pub async fn unlock_loose_prop<'e>( executor: impl PgExecutor<'e>, loose_prop_id: Uuid, ) -> Result { - let prop = sqlx::query_as::<_, LooseProp>( + let row = sqlx::query_as::<_, LoosePropRow>( r#" WITH updated AS ( UPDATE scene.loose_props @@ -603,7 +613,7 @@ pub async fn unlock_loose_prop<'e>( .await? .or_not_found("Loose prop (may have expired)")?; - Ok(prop) + Ok(LooseProp::from(row)) } /// Delete expired loose props.