minor cleanup with traits

This commit is contained in:
Evan Carroll 2026-01-23 18:27:54 -06:00
parent 73f9c95e37
commit 8a37a7b2da
17 changed files with 81 additions and 71 deletions

View file

@ -6,7 +6,7 @@ use chattyness_db::{
models::{CreateServerAvatarRequest, ServerAvatar, ServerAvatarSummary, UpdateServerAvatarRequest}, models::{CreateServerAvatarRequest, ServerAvatar, ServerAvatarSummary, UpdateServerAvatarRequest},
queries::server_avatars, queries::server_avatars,
}; };
use chattyness_error::AppError; use chattyness_error::{AppError, OptionExt};
use serde::Serialize; use serde::Serialize;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
@ -87,7 +87,7 @@ pub async fn get_avatar(
) -> Result<Json<ServerAvatar>, AppError> { ) -> Result<Json<ServerAvatar>, AppError> {
let avatar = server_avatars::get_server_avatar_by_id(&pool, avatar_id) let avatar = server_avatars::get_server_avatar_by_id(&pool, avatar_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; .or_not_found("Avatar")?;
Ok(Json(avatar)) Ok(Json(avatar))
} }
@ -106,7 +106,7 @@ pub async fn update_avatar(
// Check avatar exists // Check avatar exists
let existing = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id) let existing = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; .or_not_found("Avatar")?;
// Update the avatar // Update the avatar
let avatar = server_avatars::update_server_avatar(&mut *guard, avatar_id, &req).await?; 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 // Get the avatar first to log its name
let avatar = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id) let avatar = server_avatars::get_server_avatar_by_id(&mut *guard, avatar_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; .or_not_found("Avatar")?;
// Delete from database // Delete from database
server_avatars::delete_server_avatar(&mut *guard, avatar_id).await?; server_avatars::delete_server_avatar(&mut *guard, avatar_id).await?;

View file

@ -3,7 +3,7 @@
use axum::Json; use axum::Json;
use axum::extract::Path; use axum::extract::Path;
use chattyness_db::{models::LooseProp, queries::loose_props}; use chattyness_db::{models::LooseProp, queries::loose_props};
use chattyness_error::AppError; use chattyness_error::{AppError, OptionExt};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; 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) let prop = loose_props::get_loose_prop_by_id(&mut *guard, loose_prop_id)
.await? .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)) Ok(Json(prop))
} }

View file

@ -7,7 +7,7 @@ use chattyness_db::{
models::{CreateServerPropRequest, ServerProp, ServerPropSummary}, models::{CreateServerPropRequest, ServerProp, ServerPropSummary},
queries::props, queries::props,
}; };
use chattyness_error::AppError; use chattyness_error::{AppError, OptionExt};
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@ -218,7 +218,7 @@ pub async fn get_prop(
) -> Result<Json<ServerProp>, AppError> { ) -> Result<Json<ServerProp>, AppError> {
let prop = props::get_server_prop_by_id(&pool, prop_id) let prop = props::get_server_prop_by_id(&pool, prop_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?; .or_not_found("Prop")?;
Ok(Json(prop)) Ok(Json(prop))
} }
@ -233,7 +233,7 @@ pub async fn delete_prop(
// Get the prop first to get the asset path // Get the prop first to get the asset path
let prop = props::get_server_prop_by_id(&mut *guard, prop_id) let prop = props::get_server_prop_by_id(&mut *guard, prop_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Prop not found".to_string()))?; .or_not_found("Prop")?;
// Delete from database // Delete from database
props::delete_server_prop(&mut *guard, prop_id).await?; props::delete_server_prop(&mut *guard, prop_id).await?;

View file

@ -11,7 +11,7 @@ use chattyness_db::{
}, },
queries::{owner as queries, realm_avatars}, queries::{owner as queries, realm_avatars},
}; };
use chattyness_error::AppError; use chattyness_error::{AppError, OptionExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; 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) let avatar = realm_avatars::get_realm_avatar_by_id(&pool, avatar_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; .or_not_found("Avatar")?;
Ok(Json(avatar)) Ok(Json(avatar))
} }
@ -254,7 +254,7 @@ pub async fn update_realm_avatar(
// Check avatar exists // Check avatar exists
let existing = realm_avatars::get_realm_avatar_by_id(&mut *guard, avatar_id) let existing = realm_avatars::get_realm_avatar_by_id(&mut *guard, avatar_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; .or_not_found("Avatar")?;
// Update the avatar // Update the avatar
let avatar = realm_avatars::update_realm_avatar(&mut *guard, avatar_id, &req).await?; 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 // Get the avatar first to log its name
let avatar = realm_avatars::get_realm_avatar_by_id(&mut *guard, avatar_id) let avatar = realm_avatars::get_realm_avatar_by_id(&mut *guard, avatar_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Avatar not found".to_string()))?; .or_not_found("Avatar")?;
// Delete from database // Delete from database
realm_avatars::delete_realm_avatar(&mut *guard, avatar_id).await?; realm_avatars::delete_realm_avatar(&mut *guard, avatar_id).await?;

View file

@ -8,7 +8,7 @@ use chattyness_db::{
models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest}, models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest},
queries::{realms, scenes}, queries::{realms, scenes},
}; };
use chattyness_error::AppError; use chattyness_error::{AppError, OptionExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::PgPool; use sqlx::PgPool;
use std::path::PathBuf; use std::path::PathBuf;
@ -199,7 +199,7 @@ pub async fn get_scene(
) -> Result<Json<Scene>, AppError> { ) -> Result<Json<Scene>, AppError> {
let scene = scenes::get_scene_by_id(&pool, scene_id) let scene = scenes::get_scene_by_id(&pool, scene_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?; .or_not_found("Scene")?;
Ok(Json(scene)) Ok(Json(scene))
} }
@ -273,7 +273,7 @@ pub async fn update_scene(
// Get the existing scene to get realm_id // Get the existing scene to get realm_id
let existing_scene = scenes::get_scene_by_id(&mut *guard, scene_id) let existing_scene = scenes::get_scene_by_id(&mut *guard, scene_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?; .or_not_found("Scene")?;
// Handle clear background image // Handle clear background image
if req.clear_background_image { if req.clear_background_image {

View file

@ -8,7 +8,7 @@ use chattyness_db::{
models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest}, models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest},
queries::spots, queries::spots,
}; };
use chattyness_error::AppError; use chattyness_error::{AppError, OptionExt};
use serde::Serialize; use serde::Serialize;
use sqlx::PgPool; use sqlx::PgPool;
use uuid::Uuid; use uuid::Uuid;
@ -31,7 +31,7 @@ pub async fn get_spot(
) -> Result<Json<Spot>, AppError> { ) -> Result<Json<Spot>, AppError> {
let spot = spots::get_spot_by_id(&pool, spot_id) let spot = spots::get_spot_by_id(&pool, spot_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; .or_not_found("Spot")?;
Ok(Json(spot)) Ok(Json(spot))
} }
@ -78,7 +78,7 @@ pub async fn update_spot(
if let Some(ref new_slug) = req.slug { if let Some(ref new_slug) = req.slug {
let existing = spots::get_spot_by_id(&mut *guard, spot_id) let existing = spots::get_spot_by_id(&mut *guard, spot_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; .or_not_found("Spot")?;
if Some(new_slug.clone()) != existing.slug { if Some(new_slug.clone()) != existing.slug {
let available = let available =

View file

@ -5,6 +5,7 @@ use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use crate::components::{Card, PageHeader}; use crate::components::{Card, PageHeader};
use crate::utils::name_to_slug;
/// Server avatar new page component. /// Server avatar new page component.
#[component] #[component]
@ -28,14 +29,7 @@ pub fn AvatarsNewPage() -> impl IntoView {
let new_name = event_target_value(&ev); let new_name = event_target_value(&ev);
set_name.set(new_name.clone()); set_name.set(new_name.clone());
if slug_auto.get() { if slug_auto.get() {
let new_slug = new_name set_slug.set(name_to_slug(&new_name));
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_slug.set(new_slug);
} }
}; };

View file

@ -5,6 +5,7 @@ use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use crate::components::{Card, PageHeader}; use crate::components::{Card, PageHeader};
use crate::utils::name_to_slug;
/// Prop new page component with file upload. /// Prop new page component with file upload.
#[component] #[component]
@ -32,14 +33,7 @@ pub fn PropsNewPage() -> impl IntoView {
let new_name = event_target_value(&ev); let new_name = event_target_value(&ev);
set_name.set(new_name.clone()); set_name.set(new_name.clone());
if slug_auto.get() { if slug_auto.get() {
let new_slug = new_name set_slug.set(name_to_slug(&new_name));
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_slug.set(new_slug);
} }
}; };

View file

@ -6,6 +6,7 @@ use leptos::task::spawn_local;
use leptos_router::hooks::use_params_map; use leptos_router::hooks::use_params_map;
use crate::components::{Card, PageHeader}; use crate::components::{Card, PageHeader};
use crate::utils::name_to_slug;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use crate::utils::get_api_base; use crate::utils::get_api_base;
@ -36,14 +37,7 @@ pub fn RealmAvatarsNewPage() -> impl IntoView {
let new_name = event_target_value(&ev); let new_name = event_target_value(&ev);
set_name.set(new_name.clone()); set_name.set(new_name.clone());
if slug_auto.get() { if slug_auto.get() {
let new_slug = new_name set_avatar_slug.set(name_to_slug(&new_name));
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_avatar_slug.set(new_slug);
} }
}; };

View file

@ -5,6 +5,7 @@ use leptos::prelude::*;
use leptos::task::spawn_local; use leptos::task::spawn_local;
use crate::components::{Card, PageHeader}; use crate::components::{Card, PageHeader};
use crate::utils::name_to_slug;
/// Realm new page component. /// Realm new page component.
#[component] #[component]
@ -40,14 +41,7 @@ pub fn RealmNewPage() -> impl IntoView {
let new_name = event_target_value(&ev); let new_name = event_target_value(&ev);
set_name.set(new_name.clone()); set_name.set(new_name.clone());
if slug_auto.get() { if slug_auto.get() {
let new_slug = new_name set_slug.set(name_to_slug(&new_name));
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_slug.set(new_slug);
} }
}; };

View file

@ -6,6 +6,7 @@ use leptos::task::spawn_local;
use leptos_router::hooks::use_params_map; use leptos_router::hooks::use_params_map;
use crate::components::{Card, PageHeader}; use crate::components::{Card, PageHeader};
use crate::utils::name_to_slug;
#[cfg(feature = "hydrate")] #[cfg(feature = "hydrate")]
use crate::utils::fetch_image_dimensions_client; use crate::utils::fetch_image_dimensions_client;
@ -40,14 +41,7 @@ pub fn SceneNewPage() -> impl IntoView {
let new_name = event_target_value(&ev); let new_name = event_target_value(&ev);
set_name.set(new_name.clone()); set_name.set(new_name.clone());
if slug_auto.get() { if slug_auto.get() {
let new_slug = new_name set_slug.set(name_to_slug(&new_name));
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.trim_matches('-')
.to_string();
set_slug.set(new_slug);
} }
}; };

View file

@ -1,5 +1,24 @@
//! Utility functions for the admin UI. //! 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::<String>()
.trim_matches('-')
.to_string()
}
/// Gets the API base path based on the current URL. /// Gets the API base path based on the current URL.
/// ///
/// Returns `/api/admin` if the current path starts with `/admin`, /// Returns `/api/admin` if the current path starts with `/admin`,

View file

@ -6,7 +6,7 @@ use sqlx::PgExecutor;
use uuid::Uuid; use uuid::Uuid;
use crate::models::{InventoryItem, LooseProp}; use crate::models::{InventoryItem, LooseProp};
use chattyness_error::AppError; use chattyness_error::{AppError, OptionExt};
/// Ensure an instance exists for a scene. /// Ensure an instance exists for a scene.
/// ///
@ -330,7 +330,7 @@ pub async fn pick_up_loose_prop<'e>(
.bind(user_id) .bind(user_id)
.fetch_optional(executor) .fetch_optional(executor)
.await? .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) Ok(item)
} }
@ -396,7 +396,7 @@ pub async fn update_loose_prop_scale<'e>(
.bind(scale) .bind(scale)
.fetch_optional(executor) .fetch_optional(executor)
.await? .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) Ok(prop)
} }
@ -490,7 +490,7 @@ pub async fn move_loose_prop<'e>(
.bind(y as f32) .bind(y as f32)
.fetch_optional(executor) .fetch_optional(executor)
.await? .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) Ok(prop)
} }
@ -546,7 +546,7 @@ pub async fn lock_loose_prop<'e>(
.bind(locked_by) .bind(locked_by)
.fetch_optional(executor) .fetch_optional(executor)
.await? .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) Ok(prop)
} }
@ -600,7 +600,7 @@ pub async fn unlock_loose_prop<'e>(
.bind(loose_prop_id) .bind(loose_prop_id)
.fetch_optional(executor) .fetch_optional(executor)
.await? .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) Ok(prop)
} }

View file

@ -4,7 +4,7 @@ use sqlx::PgExecutor;
use uuid::Uuid; use uuid::Uuid;
use crate::models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest}; use crate::models::{CreateSceneRequest, Scene, SceneSummary, UpdateSceneRequest};
use chattyness_error::AppError; use chattyness_error::{AppError, OptionExt};
/// List all scenes for a realm. /// List all scenes for a realm.
pub async fn list_scenes_for_realm<'e>( pub async fn list_scenes_for_realm<'e>(
@ -374,7 +374,7 @@ pub async fn update_scene<'e>(
let scene = query_builder let scene = query_builder
.fetch_optional(executor) .fetch_optional(executor)
.await? .await?
.ok_or_else(|| AppError::NotFound("Scene not found".to_string()))?; .or_not_found("Scene")?;
Ok(scene) Ok(scene)
} }

View file

@ -4,7 +4,7 @@ use sqlx::PgExecutor;
use uuid::Uuid; use uuid::Uuid;
use crate::models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest}; use crate::models::{CreateSpotRequest, Spot, SpotSummary, UpdateSpotRequest};
use chattyness_error::AppError; use chattyness_error::{AppError, OptionExt};
/// List all spots for a scene. /// List all spots for a scene.
pub async fn list_spots_for_scene<'e>( pub async fn list_spots_for_scene<'e>(
@ -289,7 +289,7 @@ pub async fn update_spot<'e>(
let spot = query_builder let spot = query_builder
.fetch_optional(executor) .fetch_optional(executor)
.await? .await?
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; .or_not_found("Spot")?;
Ok(spot) Ok(spot)
} }

View file

@ -1,6 +1,27 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; 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<T> {
/// Convert None to AppError::NotFound with a descriptive message.
fn or_not_found(self, entity: &str) -> Result<T, AppError>;
}
impl<T> OptionExt<T> for Option<T> {
fn or_not_found(self, entity: &str) -> Result<T, AppError> {
self.ok_or_else(|| AppError::NotFound(format!("{} not found", entity)))
}
}
/// Application error types for chattyness. /// Application error types for chattyness.
/// ///
/// All errors derive From for automatic conversion where applicable. /// All errors derive From for automatic conversion where applicable.

View file

@ -11,7 +11,7 @@ use chattyness_db::{
models::{Scene, SceneSummary, Spot, SpotSummary}, models::{Scene, SceneSummary, Spot, SpotSummary},
queries::{realms, scenes, spots}, queries::{realms, scenes, spots},
}; };
use chattyness_error::AppError; use chattyness_error::{AppError, OptionExt};
/// Get the entry scene for a realm. /// Get the entry scene for a realm.
/// ///
@ -86,7 +86,7 @@ pub async fn get_spot(
) -> Result<Json<Spot>, AppError> { ) -> Result<Json<Spot>, AppError> {
let spot = spots::get_spot_by_id(&pool, spot_id) let spot = spots::get_spot_by_id(&pool, spot_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Spot not found".to_string()))?; .or_not_found("Spot")?;
Ok(Json(spot)) Ok(Json(spot))
} }