cleanup: make the modeling of props better

This commit is contained in:
Evan Carroll 2026-01-24 01:42:52 -06:00
parent 475d1ef90a
commit 4f0f88504a
2 changed files with 139 additions and 19 deletions

View file

@ -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<Utc>,
}
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<PropSource> {
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<PropAcquisitionInfo>,
}
/// 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<Uuid>,
pub realm_prop_id: Option<Uuid>,
pub position_x: f64,
pub position_y: f64,
pub scale: f32,
pub dropped_by: Option<Uuid>,
pub expires_at: Option<DateTime<Utc>>,
pub created_at: DateTime<Utc>,
pub prop_name: String,
pub prop_asset_path: String,
pub is_locked: bool,
pub locked_by: Option<Uuid>,
}
#[cfg(feature = "ssr")]
impl From<LoosePropRow> 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<Uuid>,

View file

@ -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<Vec<LooseProp>, 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<Option<LooseProp>, 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<LooseProp, AppError> {
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<LooseProp, AppError> {
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<LooseProp, AppError> {
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.