diff --git a/crates/chattyness-admin-ui/src/api/avatars.rs b/crates/chattyness-admin-ui/src/api/avatars.rs index 4eb8723..4542443 100644 --- a/crates/chattyness-admin-ui/src/api/avatars.rs +++ b/crates/chattyness-admin-ui/src/api/avatars.rs @@ -6,7 +6,7 @@ use chattyness_db::{ models::{CreateServerAvatarRequest, ServerAvatar, ServerAvatarSummary, UpdateServerAvatarRequest}, queries::server_avatars, }; -use chattyness_error::AppError; +use chattyness_error::{AppError, OptionExt}; use serde::Serialize; use sqlx::PgPool; use uuid::Uuid; @@ -87,7 +87,7 @@ pub async fn get_avatar( ) -> Result, AppError> { let avatar = server_avatars::get_server_avatar_by_id(&pool, avatar_id) .await? - .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; + .or_not_found("Avatar")?; Ok(Json(avatar)) } @@ -106,7 +106,7 @@ pub async fn update_avatar( // Check avatar exists let existing = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id) .await? - .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; + .or_not_found("Avatar")?; // Update the avatar let avatar = server_avatars::update_server_avatar(&mut *guard, avatar_id, &req).await?; @@ -127,7 +127,7 @@ pub async fn delete_avatar( // Get the avatar first to log its name let avatar = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id) .await? - .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; + .or_not_found("Avatar")?; // Delete from database server_avatars::delete_server_avatar(&mut *guard, avatar_id).await?; diff --git a/crates/chattyness-admin-ui/src/api/loose_props.rs b/crates/chattyness-admin-ui/src/api/loose_props.rs index 5231a2c..a1446bd 100644 --- a/crates/chattyness-admin-ui/src/api/loose_props.rs +++ b/crates/chattyness-admin-ui/src/api/loose_props.rs @@ -3,7 +3,7 @@ use axum::Json; use axum::extract::Path; use chattyness_db::{models::LooseProp, queries::loose_props}; -use chattyness_error::AppError; +use chattyness_error::{AppError, OptionExt}; use serde::Deserialize; use uuid::Uuid; @@ -34,7 +34,7 @@ pub async fn get_loose_prop( let prop = loose_props::get_loose_prop_by_id(&mut *guard, loose_prop_id) .await? - .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; + .or_not_found("Loose prop (may have expired)")?; Ok(Json(prop)) } diff --git a/crates/chattyness-admin-ui/src/api/props.rs b/crates/chattyness-admin-ui/src/api/props.rs index c7c7d7f..549a08a 100644 --- a/crates/chattyness-admin-ui/src/api/props.rs +++ b/crates/chattyness-admin-ui/src/api/props.rs @@ -7,7 +7,7 @@ use chattyness_db::{ models::{CreateServerPropRequest, ServerProp, ServerPropSummary}, queries::props, }; -use chattyness_error::AppError; +use chattyness_error::{AppError, OptionExt}; use serde::Deserialize; use serde::Serialize; use sha2::{Digest, Sha256}; @@ -218,7 +218,7 @@ pub async fn get_prop( ) -> Result, AppError> { let prop = props::get_server_prop_by_id(&pool, prop_id) .await? - .ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?; + .or_not_found("Prop")?; Ok(Json(prop)) } @@ -233,7 +233,7 @@ pub async fn delete_prop( // Get the prop first to get the asset path let prop = props::get_server_prop_by_id(&mut *guard, prop_id) .await? - .ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?; + .or_not_found("Prop")?; // Delete from database props::delete_server_prop(&mut *guard, prop_id).await?; diff --git a/crates/chattyness-admin-ui/src/api/realms.rs b/crates/chattyness-admin-ui/src/api/realms.rs index 0cb1cbd..92f1b9a 100644 --- a/crates/chattyness-admin-ui/src/api/realms.rs +++ b/crates/chattyness-admin-ui/src/api/realms.rs @@ -11,7 +11,7 @@ use chattyness_db::{ }, queries::{owner as queries, realm_avatars}, }; -use chattyness_error::AppError; +use chattyness_error::{AppError, OptionExt}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use uuid::Uuid; @@ -231,7 +231,7 @@ pub async fn get_realm_avatar( let avatar = realm_avatars::get_realm_avatar_by_id(&pool, avatar_id) .await? - .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; + .or_not_found("Avatar")?; Ok(Json(avatar)) } @@ -254,7 +254,7 @@ pub async fn update_realm_avatar( // Check avatar exists let existing = realm_avatars::get_realm_avatar_by_id(&mut *guard, avatar_id) .await? - .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; + .or_not_found("Avatar")?; // Update the avatar let avatar = realm_avatars::update_realm_avatar(&mut *guard, avatar_id, &req).await?; @@ -284,7 +284,7 @@ pub async fn delete_realm_avatar( // Get the avatar first to log its name let avatar = realm_avatars::get_realm_avatar_by_id(&mut *guard, avatar_id) .await? - .ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; + .or_not_found("Avatar")?; // Delete from database realm_avatars::delete_realm_avatar(&mut *guard, avatar_id).await?; diff --git a/crates/chattyness-admin-ui/src/api/scenes.rs b/crates/chattyness-admin-ui/src/api/scenes.rs index c5d3d74..5d7c649 100644 --- a/crates/chattyness-admin-ui/src/api/scenes.rs +++ b/crates/chattyness-admin-ui/src/api/scenes.rs @@ -8,7 +8,7 @@ use chattyness_db::{ models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest}, queries::{realms, scenes}, }; -use chattyness_error::AppError; +use chattyness_error::{AppError, OptionExt}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::path::PathBuf; @@ -199,7 +199,7 @@ pub async fn get_scene( ) -> Result, AppError> { let scene = scenes::get_scene_by_id(&pool, scene_id) .await? - .ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?; + .or_not_found("Scene")?; Ok(Json(scene)) } @@ -273,7 +273,7 @@ pub async fn update_scene( // Get the existing scene to get realm_id let existing_scene = scenes::get_scene_by_id(&mut *guard, scene_id) .await? - .ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?; + .or_not_found("Scene")?; // Handle clear background image if req.clear_background_image { diff --git a/crates/chattyness-admin-ui/src/api/spots.rs b/crates/chattyness-admin-ui/src/api/spots.rs index 7500f7b..8351ab7 100644 --- a/crates/chattyness-admin-ui/src/api/spots.rs +++ b/crates/chattyness-admin-ui/src/api/spots.rs @@ -8,7 +8,7 @@ use chattyness_db::{ models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest}, queries::spots, }; -use chattyness_error::AppError; +use chattyness_error::{AppError, OptionExt}; use serde::Serialize; use sqlx::PgPool; use uuid::Uuid; @@ -31,7 +31,7 @@ pub async fn get_spot( ) -> Result, AppError> { let spot = spots::get_spot_by_id(&pool, spot_id) .await? - .ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; + .or_not_found("Spot")?; Ok(Json(spot)) } @@ -78,7 +78,7 @@ pub async fn update_spot( if let Some(ref new_slug) = req.slug { let existing = spots::get_spot_by_id(&mut *guard, spot_id) .await? - .ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; + .or_not_found("Spot")?; if Some(new_slug.clone()) != existing.slug { let available = diff --git a/crates/chattyness-admin-ui/src/pages/avatars_new.rs b/crates/chattyness-admin-ui/src/pages/avatars_new.rs index a01b2fd..9ba1da2 100644 --- a/crates/chattyness-admin-ui/src/pages/avatars_new.rs +++ b/crates/chattyness-admin-ui/src/pages/avatars_new.rs @@ -5,6 +5,7 @@ use leptos::prelude::*; use leptos::task::spawn_local; use crate::components::{Card, PageHeader}; +use crate::utils::name_to_slug; /// Server avatar new page component. #[component] @@ -28,14 +29,7 @@ pub fn AvatarsNewPage() -> impl IntoView { let new_name = event_target_value(&ev); set_name.set(new_name.clone()); if slug_auto.get() { - let new_slug = new_name - .to_lowercase() - .chars() - .map(|c| if c.is_alphanumeric() { c } else { '-' }) - .collect::() - .trim_matches('-') - .to_string(); - set_slug.set(new_slug); + set_slug.set(name_to_slug(&new_name)); } }; diff --git a/crates/chattyness-admin-ui/src/pages/props_new.rs b/crates/chattyness-admin-ui/src/pages/props_new.rs index da961fe..1b97e8c 100644 --- a/crates/chattyness-admin-ui/src/pages/props_new.rs +++ b/crates/chattyness-admin-ui/src/pages/props_new.rs @@ -5,6 +5,7 @@ use leptos::prelude::*; use leptos::task::spawn_local; use crate::components::{Card, PageHeader}; +use crate::utils::name_to_slug; /// Prop new page component with file upload. #[component] @@ -32,14 +33,7 @@ pub fn PropsNewPage() -> impl IntoView { let new_name = event_target_value(&ev); set_name.set(new_name.clone()); if slug_auto.get() { - let new_slug = new_name - .to_lowercase() - .chars() - .map(|c| if c.is_alphanumeric() { c } else { '-' }) - .collect::() - .trim_matches('-') - .to_string(); - set_slug.set(new_slug); + set_slug.set(name_to_slug(&new_name)); } }; diff --git a/crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs b/crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs index e8e8118..b41cb74 100644 --- a/crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs +++ b/crates/chattyness-admin-ui/src/pages/realm_avatars_new.rs @@ -6,6 +6,7 @@ use leptos::task::spawn_local; use leptos_router::hooks::use_params_map; use crate::components::{Card, PageHeader}; +use crate::utils::name_to_slug; #[cfg(feature = "hydrate")] use crate::utils::get_api_base; @@ -36,14 +37,7 @@ pub fn RealmAvatarsNewPage() -> impl IntoView { let new_name = event_target_value(&ev); set_name.set(new_name.clone()); if slug_auto.get() { - let new_slug = new_name - .to_lowercase() - .chars() - .map(|c| if c.is_alphanumeric() { c } else { '-' }) - .collect::() - .trim_matches('-') - .to_string(); - set_avatar_slug.set(new_slug); + set_avatar_slug.set(name_to_slug(&new_name)); } }; diff --git a/crates/chattyness-admin-ui/src/pages/realm_new.rs b/crates/chattyness-admin-ui/src/pages/realm_new.rs index f520428..0a7085d 100644 --- a/crates/chattyness-admin-ui/src/pages/realm_new.rs +++ b/crates/chattyness-admin-ui/src/pages/realm_new.rs @@ -5,6 +5,7 @@ use leptos::prelude::*; use leptos::task::spawn_local; use crate::components::{Card, PageHeader}; +use crate::utils::name_to_slug; /// Realm new page component. #[component] @@ -40,14 +41,7 @@ pub fn RealmNewPage() -> impl IntoView { let new_name = event_target_value(&ev); set_name.set(new_name.clone()); if slug_auto.get() { - let new_slug = new_name - .to_lowercase() - .chars() - .map(|c| if c.is_alphanumeric() { c } else { '-' }) - .collect::() - .trim_matches('-') - .to_string(); - set_slug.set(new_slug); + set_slug.set(name_to_slug(&new_name)); } }; diff --git a/crates/chattyness-admin-ui/src/pages/scene_new.rs b/crates/chattyness-admin-ui/src/pages/scene_new.rs index 32dee1a..a4b9e8e 100644 --- a/crates/chattyness-admin-ui/src/pages/scene_new.rs +++ b/crates/chattyness-admin-ui/src/pages/scene_new.rs @@ -6,6 +6,7 @@ use leptos::task::spawn_local; use leptos_router::hooks::use_params_map; use crate::components::{Card, PageHeader}; +use crate::utils::name_to_slug; #[cfg(feature = "hydrate")] use crate::utils::fetch_image_dimensions_client; @@ -40,14 +41,7 @@ pub fn SceneNewPage() -> impl IntoView { let new_name = event_target_value(&ev); set_name.set(new_name.clone()); if slug_auto.get() { - let new_slug = new_name - .to_lowercase() - .chars() - .map(|c| if c.is_alphanumeric() { c } else { '-' }) - .collect::() - .trim_matches('-') - .to_string(); - set_slug.set(new_slug); + set_slug.set(name_to_slug(&new_name)); } }; diff --git a/crates/chattyness-admin-ui/src/utils.rs b/crates/chattyness-admin-ui/src/utils.rs index 77c37a2..4c5341f 100644 --- a/crates/chattyness-admin-ui/src/utils.rs +++ b/crates/chattyness-admin-ui/src/utils.rs @@ -1,5 +1,24 @@ //! Utility functions for the admin UI. +/// Generate a URL-friendly slug from a name. +/// +/// Converts to lowercase, replaces non-alphanumeric chars with hyphens, +/// and trims leading/trailing hyphens. +/// +/// # Example +/// ```rust +/// assert_eq!(name_to_slug("My Cool Realm!"), "my-cool-realm-"); +/// assert_eq!(name_to_slug("Test 123"), "test-123"); +/// ``` +pub fn name_to_slug(name: &str) -> String { + name.to_lowercase() + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::() + .trim_matches('-') + .to_string() +} + /// Gets the API base path based on the current URL. /// /// Returns `/api/admin` if the current path starts with `/admin`, diff --git a/crates/chattyness-db/src/queries/loose_props.rs b/crates/chattyness-db/src/queries/loose_props.rs index b1de987..70eb3cb 100644 --- a/crates/chattyness-db/src/queries/loose_props.rs +++ b/crates/chattyness-db/src/queries/loose_props.rs @@ -6,7 +6,7 @@ use sqlx::PgExecutor; use uuid::Uuid; use crate::models::{InventoryItem, LooseProp}; -use chattyness_error::AppError; +use chattyness_error::{AppError, OptionExt}; /// Ensure an instance exists for a scene. /// @@ -330,7 +330,7 @@ pub async fn pick_up_loose_prop<'e>( .bind(user_id) .fetch_optional(executor) .await? - .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; + .or_not_found("Loose prop (may have expired)")?; Ok(item) } @@ -396,7 +396,7 @@ pub async fn update_loose_prop_scale<'e>( .bind(scale) .fetch_optional(executor) .await? - .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; + .or_not_found("Loose prop (may have expired)")?; Ok(prop) } @@ -490,7 +490,7 @@ pub async fn move_loose_prop<'e>( .bind(y as f32) .fetch_optional(executor) .await? - .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; + .or_not_found("Loose prop (may have expired)")?; Ok(prop) } @@ -546,7 +546,7 @@ pub async fn lock_loose_prop<'e>( .bind(locked_by) .fetch_optional(executor) .await? - .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; + .or_not_found("Loose prop (may have expired)")?; Ok(prop) } @@ -600,7 +600,7 @@ pub async fn unlock_loose_prop<'e>( .bind(loose_prop_id) .fetch_optional(executor) .await? - .ok_or_else(|| AppError::NotFound("Loose prop not found or has expired".to_string()))?; + .or_not_found("Loose prop (may have expired)")?; Ok(prop) } diff --git a/crates/chattyness-db/src/queries/scenes.rs b/crates/chattyness-db/src/queries/scenes.rs index 5d6405e..7b8f4ed 100644 --- a/crates/chattyness-db/src/queries/scenes.rs +++ b/crates/chattyness-db/src/queries/scenes.rs @@ -4,7 +4,7 @@ use sqlx::PgExecutor; use uuid::Uuid; use crate::models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest}; -use chattyness_error::AppError; +use chattyness_error::{AppError, OptionExt}; /// List all scenes for a realm. pub async fn list_scenes_for_realm<'e>( @@ -374,7 +374,7 @@ pub async fn update_scene<'e>( let scene = query_builder .fetch_optional(executor) .await? - .ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?; + .or_not_found("Scene")?; Ok(scene) } diff --git a/crates/chattyness-db/src/queries/spots.rs b/crates/chattyness-db/src/queries/spots.rs index 7160c9f..3b6fa7b 100644 --- a/crates/chattyness-db/src/queries/spots.rs +++ b/crates/chattyness-db/src/queries/spots.rs @@ -4,7 +4,7 @@ use sqlx::PgExecutor; use uuid::Uuid; use crate::models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest}; -use chattyness_error::AppError; +use chattyness_error::{AppError, OptionExt}; /// List all spots for a scene. pub async fn list_spots_for_scene<'e>( @@ -289,7 +289,7 @@ pub async fn update_spot<'e>( let spot = query_builder .fetch_optional(executor) .await? - .ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; + .or_not_found("Spot")?; Ok(spot) } diff --git a/crates/chattyness-error/src/lib.rs b/crates/chattyness-error/src/lib.rs index fb6e4ff..d516d19 100644 --- a/crates/chattyness-error/src/lib.rs +++ b/crates/chattyness-error/src/lib.rs @@ -1,6 +1,27 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; +/// Extension trait for Option to convert to AppError::NotFound. +/// +/// Reduces boilerplate when fetching entities that may not exist. +/// +/// # Example +/// ```rust +/// use chattyness_error::OptionExt; +/// +/// let scene = get_scene_by_id(&pool, id).await?.or_not_found("Scene")?; +/// ``` +pub trait OptionExt { + /// Convert None to AppError::NotFound with a descriptive message. + fn or_not_found(self, entity: &str) -> Result; +} + +impl OptionExt for Option { + fn or_not_found(self, entity: &str) -> Result { + self.ok_or_else(|| AppError::NotFound(format!("{} not found", entity))) + } +} + /// Application error types for chattyness. /// /// All errors derive From for automatic conversion where applicable. diff --git a/crates/chattyness-user-ui/src/api/scenes.rs b/crates/chattyness-user-ui/src/api/scenes.rs index d83f99a..8d9f857 100644 --- a/crates/chattyness-user-ui/src/api/scenes.rs +++ b/crates/chattyness-user-ui/src/api/scenes.rs @@ -11,7 +11,7 @@ use chattyness_db::{ models::{Scene, SceneSummary, Spot, SpotSummary}, queries::{realms, scenes, spots}, }; -use chattyness_error::AppError; +use chattyness_error::{AppError, OptionExt}; /// Get the entry scene for a realm. /// @@ -86,7 +86,7 @@ pub async fn get_spot( ) -> Result, AppError> { let spot = spots::get_spot_by_id(&pool, spot_id) .await? - .ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; + .or_not_found("Spot")?; Ok(Json(spot)) }