chattyness/crates/chattyness-db/src/queries/props.rs

275 lines
7.3 KiB
Rust

//! 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<Vec<ServerPropSummary>, 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<Option<ServerProp>, 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<bool, AppError> {
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<Uuid>,
) -> Result<ServerProp, AppError> {
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<Uuid>,
) -> Result<ServerProp, AppError> {
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(())
}