//! Props-related database queries. use sqlx::PgExecutor; use uuid::Uuid; use crate::models::{CreateServerPropRequest, ServerProp, ServerPropSummary}; use chattyness_error::AppError; /// List all server props. pub async fn list_server_props<'e>( executor: impl PgExecutor<'e>, ) -> Result, AppError> { let props = sqlx::query_as::<_, ServerPropSummary>( r#" SELECT id, name, slug, asset_path, default_layer, is_active, created_at FROM server.props ORDER BY name ASC "#, ) .fetch_all(executor) .await?; Ok(props) } /// Get a server prop by ID. pub async fn get_server_prop_by_id<'e>( executor: impl PgExecutor<'e>, prop_id: Uuid, ) -> Result, AppError> { let prop = sqlx::query_as::<_, ServerProp>( r#" SELECT id, name, slug, description, tags, asset_path, thumbnail_path, default_layer, default_emotion, default_position, is_unique, is_transferable, is_portable, is_droppable, is_active, available_from, available_until, created_by, created_at, updated_at FROM server.props WHERE id = $1 "#, ) .bind(prop_id) .fetch_optional(executor) .await?; Ok(prop) } /// Check if a prop slug is available. pub async fn is_prop_slug_available<'e>( executor: impl PgExecutor<'e>, slug: &str, ) -> Result { let exists: (bool,) = sqlx::query_as(r#"SELECT EXISTS(SELECT 1 FROM server.props WHERE slug = $1)"#) .bind(slug) .fetch_one(executor) .await?; Ok(!exists.0) } /// Create a new server prop. pub async fn create_server_prop<'e>( executor: impl PgExecutor<'e>, req: &CreateServerPropRequest, asset_path: &str, created_by: Option, ) -> Result { let slug = req.slug_or_generate(); // Positioning: either content layer OR emotion layer OR neither (all NULL) // Database constraint enforces mutual exclusivity let (default_layer, default_emotion, default_position) = if req.default_layer.is_some() { // Content layer prop ( req.default_layer.map(|l| l.to_string()), None, Some(req.default_position.unwrap_or(4)), // Default to center position ) } else if req.default_emotion.is_some() { // Emotion layer prop ( None, req.default_emotion.map(|e| e.to_string()), Some(req.default_position.unwrap_or(4)), // Default to center position ) } else { // Non-avatar prop (None, None, None) }; let prop = sqlx::query_as::<_, ServerProp>( r#" INSERT INTO server.props ( name, slug, description, tags, asset_path, default_layer, default_emotion, default_position, created_by ) VALUES ( $1, $2, $3, $4, $5, $6::server.avatar_layer, $7::server.emotion_state, $8, $9 ) RETURNING id, name, slug, description, tags, asset_path, thumbnail_path, default_layer, default_emotion, default_position, is_unique, is_transferable, is_portable, is_droppable, is_active, available_from, available_until, created_by, created_at, updated_at "#, ) .bind(&req.name) .bind(&slug) .bind(&req.description) .bind(&req.tags) .bind(asset_path) .bind(&default_layer) .bind(&default_emotion) .bind(default_position) .bind(created_by) .fetch_one(executor) .await?; Ok(prop) } /// Upsert a server prop (insert or update on slug conflict). /// /// If a prop with the same slug already exists, it will be updated. /// Otherwise, a new prop will be created. pub async fn upsert_server_prop<'e>( executor: impl PgExecutor<'e>, req: &CreateServerPropRequest, asset_path: &str, created_by: Option, ) -> Result { let slug = req.slug_or_generate(); // Positioning: either content layer OR emotion layer OR neither (all NULL) // Database constraint enforces mutual exclusivity let (default_layer, default_emotion, default_position) = if req.default_layer.is_some() { // Content layer prop ( req.default_layer.map(|l| l.to_string()), None, Some(req.default_position.unwrap_or(4)), // Default to center position ) } else if req.default_emotion.is_some() { // Emotion layer prop ( None, req.default_emotion.map(|e| e.to_string()), Some(req.default_position.unwrap_or(4)), // Default to center position ) } else { // Non-avatar prop (None, None, None) }; let prop = sqlx::query_as::<_, ServerProp>( r#" INSERT INTO server.props ( name, slug, description, tags, asset_path, default_layer, default_emotion, default_position, created_by ) VALUES ( $1, $2, $3, $4, $5, $6::server.avatar_layer, $7::server.emotion_state, $8, $9 ) ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, tags = EXCLUDED.tags, asset_path = EXCLUDED.asset_path, default_layer = EXCLUDED.default_layer, default_emotion = EXCLUDED.default_emotion, default_position = EXCLUDED.default_position, updated_at = now() RETURNING id, name, slug, description, tags, asset_path, thumbnail_path, default_layer, default_emotion, default_position, is_unique, is_transferable, is_portable, is_droppable, is_active, available_from, available_until, created_by, created_at, updated_at "#, ) .bind(&req.name) .bind(&slug) .bind(&req.description) .bind(&req.tags) .bind(asset_path) .bind(&default_layer) .bind(&default_emotion) .bind(default_position) .bind(created_by) .fetch_one(executor) .await?; Ok(prop) } /// Delete a server prop. pub async fn delete_server_prop<'e>( executor: impl PgExecutor<'e>, prop_id: Uuid, ) -> Result<(), AppError> { let result = sqlx::query(r#"DELETE FROM server.props WHERE id = $1"#) .bind(prop_id) .execute(executor) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("Prop not found".to_string())); } Ok(()) }