chattyness/crates/chattyness-db/src/queries/avatars.rs
Evan Carroll 6fb90e42c3 Rework avatars.
Now we have a concept of an avatar at the server, realm, and scene level
and we have the groundwork for a realm store. New uesrs no longer props,
they get a default avatar. New system supports gender
{male,female,neutral} and {child,adult}.
2026-01-22 21:04:27 -06:00

2162 lines
73 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Avatar-related database queries.
use std::collections::HashMap;
use sqlx::{PgExecutor, PgPool, postgres::PgConnection};
use uuid::Uuid;
use crate::models::{ActiveAvatar, AvatarWithPaths, EmotionAvailability, EmotionState};
use chattyness_error::AppError;
/// Get the active avatar for a user in a realm.
pub async fn get_active_avatar<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
) -> Result<Option<ActiveAvatar>, AppError> {
let avatar = sqlx::query_as::<_, ActiveAvatar>(
r#"
SELECT
user_id, realm_id, avatar_id,
selected_server_avatar_id, selected_realm_avatar_id,
current_emotion, updated_at,
forced_avatar_id, forced_avatar_source, forced_by, forced_until
FROM auth.active_avatars
WHERE user_id = $1 AND realm_id = $2
"#,
)
.bind(user_id)
.bind(realm_id)
.fetch_optional(executor)
.await?;
Ok(avatar)
}
/// Set the current emotion for a user in a realm.
/// Returns the full emotion layer (9 asset paths) for the new emotion.
///
/// This function works with any avatar source:
/// - Custom user avatars (auth.avatars)
/// - Selected server avatars (server.avatars)
/// - Selected realm avatars (realm.avatars)
/// - Server default avatars (server.avatars via server.config)
/// - Realm default avatars (realm.avatars via realm.realms)
///
/// Takes both a connection (for RLS-protected update) and a pool (for avatar resolution).
pub async fn set_emotion<'e>(
conn: &mut PgConnection,
pool: &PgPool,
user_id: Uuid,
realm_id: Uuid,
emotion: EmotionState,
) -> Result<[Option<String>; 9], AppError> {
// First, update the emotion in active_avatars (uses RLS connection)
let update_result = sqlx::query(
r#"
UPDATE auth.active_avatars
SET current_emotion = $3::server.emotion_state, updated_at = now()
WHERE user_id = $1 AND realm_id = $2
"#,
)
.bind(user_id)
.bind(realm_id)
.bind(emotion.to_string())
.execute(&mut *conn)
.await?;
if update_result.rows_affected() == 0 {
return Err(AppError::NotFound(
"No active avatar for this user in this realm".to_string(),
));
}
// Now get the effective avatar and return the emotion layer (uses pool for multiple queries)
let render_data = get_effective_avatar_render_data(pool, user_id, realm_id).await?;
match render_data {
Some((data, _source)) => Ok(data.emotion_layer),
None => {
// No avatar found - return empty layer
Ok([None, None, None, None, None, None, None, None, None])
}
}
}
/// Get emotion availability for a user's avatar in a realm.
///
/// Returns which emotions have assets configured (any of positions 0-8 non-null)
/// and the center position (4) preview path for each emotion.
pub async fn get_emotion_availability<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
) -> Result<EmotionAvailability, AppError> {
let row = sqlx::query_as::<_, EmotionAvailabilityRow>(
r#"
SELECT
-- Neutral (0): check if any position has asset
(a.e_neutral_0 IS NOT NULL OR a.e_neutral_1 IS NOT NULL OR a.e_neutral_2 IS NOT NULL OR
a.e_neutral_3 IS NOT NULL OR a.e_neutral_4 IS NOT NULL OR a.e_neutral_5 IS NOT NULL OR
a.e_neutral_6 IS NOT NULL OR a.e_neutral_7 IS NOT NULL OR a.e_neutral_8 IS NOT NULL) as avail_0,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_neutral_4) as preview_0,
-- Happy (1)
(a.e_happy_0 IS NOT NULL OR a.e_happy_1 IS NOT NULL OR a.e_happy_2 IS NOT NULL OR
a.e_happy_3 IS NOT NULL OR a.e_happy_4 IS NOT NULL OR a.e_happy_5 IS NOT NULL OR
a.e_happy_6 IS NOT NULL OR a.e_happy_7 IS NOT NULL OR a.e_happy_8 IS NOT NULL) as avail_1,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_happy_4) as preview_1,
-- Sad (2)
(a.e_sad_0 IS NOT NULL OR a.e_sad_1 IS NOT NULL OR a.e_sad_2 IS NOT NULL OR
a.e_sad_3 IS NOT NULL OR a.e_sad_4 IS NOT NULL OR a.e_sad_5 IS NOT NULL OR
a.e_sad_6 IS NOT NULL OR a.e_sad_7 IS NOT NULL OR a.e_sad_8 IS NOT NULL) as avail_2,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_sad_4) as preview_2,
-- Angry (3)
(a.e_angry_0 IS NOT NULL OR a.e_angry_1 IS NOT NULL OR a.e_angry_2 IS NOT NULL OR
a.e_angry_3 IS NOT NULL OR a.e_angry_4 IS NOT NULL OR a.e_angry_5 IS NOT NULL OR
a.e_angry_6 IS NOT NULL OR a.e_angry_7 IS NOT NULL OR a.e_angry_8 IS NOT NULL) as avail_3,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_angry_4) as preview_3,
-- Surprised (4)
(a.e_surprised_0 IS NOT NULL OR a.e_surprised_1 IS NOT NULL OR a.e_surprised_2 IS NOT NULL OR
a.e_surprised_3 IS NOT NULL OR a.e_surprised_4 IS NOT NULL OR a.e_surprised_5 IS NOT NULL OR
a.e_surprised_6 IS NOT NULL OR a.e_surprised_7 IS NOT NULL OR a.e_surprised_8 IS NOT NULL) as avail_4,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_surprised_4) as preview_4,
-- Thinking (5)
(a.e_thinking_0 IS NOT NULL OR a.e_thinking_1 IS NOT NULL OR a.e_thinking_2 IS NOT NULL OR
a.e_thinking_3 IS NOT NULL OR a.e_thinking_4 IS NOT NULL OR a.e_thinking_5 IS NOT NULL OR
a.e_thinking_6 IS NOT NULL OR a.e_thinking_7 IS NOT NULL OR a.e_thinking_8 IS NOT NULL) as avail_5,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_thinking_4) as preview_5,
-- Laughing (6)
(a.e_laughing_0 IS NOT NULL OR a.e_laughing_1 IS NOT NULL OR a.e_laughing_2 IS NOT NULL OR
a.e_laughing_3 IS NOT NULL OR a.e_laughing_4 IS NOT NULL OR a.e_laughing_5 IS NOT NULL OR
a.e_laughing_6 IS NOT NULL OR a.e_laughing_7 IS NOT NULL OR a.e_laughing_8 IS NOT NULL) as avail_6,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_laughing_4) as preview_6,
-- Crying (7)
(a.e_crying_0 IS NOT NULL OR a.e_crying_1 IS NOT NULL OR a.e_crying_2 IS NOT NULL OR
a.e_crying_3 IS NOT NULL OR a.e_crying_4 IS NOT NULL OR a.e_crying_5 IS NOT NULL OR
a.e_crying_6 IS NOT NULL OR a.e_crying_7 IS NOT NULL OR a.e_crying_8 IS NOT NULL) as avail_7,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_crying_4) as preview_7,
-- Love (8)
(a.e_love_0 IS NOT NULL OR a.e_love_1 IS NOT NULL OR a.e_love_2 IS NOT NULL OR
a.e_love_3 IS NOT NULL OR a.e_love_4 IS NOT NULL OR a.e_love_5 IS NOT NULL OR
a.e_love_6 IS NOT NULL OR a.e_love_7 IS NOT NULL OR a.e_love_8 IS NOT NULL) as avail_8,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_love_4) as preview_8,
-- Confused (9)
(a.e_confused_0 IS NOT NULL OR a.e_confused_1 IS NOT NULL OR a.e_confused_2 IS NOT NULL OR
a.e_confused_3 IS NOT NULL OR a.e_confused_4 IS NOT NULL OR a.e_confused_5 IS NOT NULL OR
a.e_confused_6 IS NOT NULL OR a.e_confused_7 IS NOT NULL OR a.e_confused_8 IS NOT NULL) as avail_9,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_confused_4) as preview_9,
-- Sleeping (10)
(a.e_sleeping_0 IS NOT NULL OR a.e_sleeping_1 IS NOT NULL OR a.e_sleeping_2 IS NOT NULL OR
a.e_sleeping_3 IS NOT NULL OR a.e_sleeping_4 IS NOT NULL OR a.e_sleeping_5 IS NOT NULL OR
a.e_sleeping_6 IS NOT NULL OR a.e_sleeping_7 IS NOT NULL OR a.e_sleeping_8 IS NOT NULL) as avail_10,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_sleeping_4) as preview_10,
-- Wink (11)
(a.e_wink_0 IS NOT NULL OR a.e_wink_1 IS NOT NULL OR a.e_wink_2 IS NOT NULL OR
a.e_wink_3 IS NOT NULL OR a.e_wink_4 IS NOT NULL OR a.e_wink_5 IS NOT NULL OR
a.e_wink_6 IS NOT NULL OR a.e_wink_7 IS NOT NULL OR a.e_wink_8 IS NOT NULL) as avail_11,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.e_wink_4) as preview_11
FROM auth.active_avatars aa
JOIN auth.avatars a ON aa.avatar_id = a.id
WHERE aa.user_id = $1 AND aa.realm_id = $2
"#,
)
.bind(user_id)
.bind(realm_id)
.fetch_optional(executor)
.await?;
match row {
Some(r) => Ok(r.into()),
None => Ok(EmotionAvailability::default()),
}
}
/// Row type for emotion availability query.
#[derive(Debug, sqlx::FromRow)]
struct EmotionAvailabilityRow {
avail_0: Option<bool>,
preview_0: Option<String>,
avail_1: Option<bool>,
preview_1: Option<String>,
avail_2: Option<bool>,
preview_2: Option<String>,
avail_3: Option<bool>,
preview_3: Option<String>,
avail_4: Option<bool>,
preview_4: Option<String>,
avail_5: Option<bool>,
preview_5: Option<String>,
avail_6: Option<bool>,
preview_6: Option<String>,
avail_7: Option<bool>,
preview_7: Option<String>,
avail_8: Option<bool>,
preview_8: Option<String>,
avail_9: Option<bool>,
preview_9: Option<String>,
avail_10: Option<bool>,
preview_10: Option<String>,
avail_11: Option<bool>,
preview_11: Option<String>,
}
impl From<EmotionAvailabilityRow> for EmotionAvailability {
fn from(row: EmotionAvailabilityRow) -> Self {
Self {
available: [
row.avail_0.unwrap_or(false),
row.avail_1.unwrap_or(false),
row.avail_2.unwrap_or(false),
row.avail_3.unwrap_or(false),
row.avail_4.unwrap_or(false),
row.avail_5.unwrap_or(false),
row.avail_6.unwrap_or(false),
row.avail_7.unwrap_or(false),
row.avail_8.unwrap_or(false),
row.avail_9.unwrap_or(false),
row.avail_10.unwrap_or(false),
row.avail_11.unwrap_or(false),
],
preview_paths: [
row.preview_0,
row.preview_1,
row.preview_2,
row.preview_3,
row.preview_4,
row.preview_5,
row.preview_6,
row.preview_7,
row.preview_8,
row.preview_9,
row.preview_10,
row.preview_11,
],
}
}
}
/// Get the full avatar with all inventory UUIDs resolved to asset paths.
///
/// This function uses two queries:
/// 1. Fetch the avatar row with all 135 UUID slots
/// 2. Bulk resolve all UUIDs to asset paths with a single inventory query
///
/// The result enables client-side emotion availability computation and rendering.
pub async fn get_avatar_with_paths(
pool: &PgPool,
user_id: Uuid,
realm_id: Uuid,
) -> Result<Option<AvatarWithPaths>, AppError> {
// Query 1: Get the avatar row with current_emotion from active_avatars
let avatar_row = sqlx::query_as::<_, AvatarWithEmotion>(
r#"
SELECT
a.*,
aa.current_emotion
FROM auth.active_avatars aa
JOIN auth.avatars a ON aa.avatar_id = a.id
WHERE aa.user_id = $1 AND aa.realm_id = $2
"#,
)
.bind(user_id)
.bind(realm_id)
.fetch_optional(pool)
.await?;
let Some(avatar) = avatar_row else {
return Ok(None);
};
// Collect all non-null UUIDs from the avatar slots
let mut uuids: Vec<Uuid> = Vec::new();
// Content layers
collect_uuids(
&mut uuids,
&[
avatar.l_skin_0,
avatar.l_skin_1,
avatar.l_skin_2,
avatar.l_skin_3,
avatar.l_skin_4,
avatar.l_skin_5,
avatar.l_skin_6,
avatar.l_skin_7,
avatar.l_skin_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.l_clothes_0,
avatar.l_clothes_1,
avatar.l_clothes_2,
avatar.l_clothes_3,
avatar.l_clothes_4,
avatar.l_clothes_5,
avatar.l_clothes_6,
avatar.l_clothes_7,
avatar.l_clothes_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.l_accessories_0,
avatar.l_accessories_1,
avatar.l_accessories_2,
avatar.l_accessories_3,
avatar.l_accessories_4,
avatar.l_accessories_5,
avatar.l_accessories_6,
avatar.l_accessories_7,
avatar.l_accessories_8,
],
);
// Emotion layers (12 emotions × 9 positions)
collect_uuids(
&mut uuids,
&[
avatar.e_neutral_0,
avatar.e_neutral_1,
avatar.e_neutral_2,
avatar.e_neutral_3,
avatar.e_neutral_4,
avatar.e_neutral_5,
avatar.e_neutral_6,
avatar.e_neutral_7,
avatar.e_neutral_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_happy_0,
avatar.e_happy_1,
avatar.e_happy_2,
avatar.e_happy_3,
avatar.e_happy_4,
avatar.e_happy_5,
avatar.e_happy_6,
avatar.e_happy_7,
avatar.e_happy_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_sad_0,
avatar.e_sad_1,
avatar.e_sad_2,
avatar.e_sad_3,
avatar.e_sad_4,
avatar.e_sad_5,
avatar.e_sad_6,
avatar.e_sad_7,
avatar.e_sad_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_angry_0,
avatar.e_angry_1,
avatar.e_angry_2,
avatar.e_angry_3,
avatar.e_angry_4,
avatar.e_angry_5,
avatar.e_angry_6,
avatar.e_angry_7,
avatar.e_angry_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_surprised_0,
avatar.e_surprised_1,
avatar.e_surprised_2,
avatar.e_surprised_3,
avatar.e_surprised_4,
avatar.e_surprised_5,
avatar.e_surprised_6,
avatar.e_surprised_7,
avatar.e_surprised_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_thinking_0,
avatar.e_thinking_1,
avatar.e_thinking_2,
avatar.e_thinking_3,
avatar.e_thinking_4,
avatar.e_thinking_5,
avatar.e_thinking_6,
avatar.e_thinking_7,
avatar.e_thinking_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_laughing_0,
avatar.e_laughing_1,
avatar.e_laughing_2,
avatar.e_laughing_3,
avatar.e_laughing_4,
avatar.e_laughing_5,
avatar.e_laughing_6,
avatar.e_laughing_7,
avatar.e_laughing_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_crying_0,
avatar.e_crying_1,
avatar.e_crying_2,
avatar.e_crying_3,
avatar.e_crying_4,
avatar.e_crying_5,
avatar.e_crying_6,
avatar.e_crying_7,
avatar.e_crying_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_love_0,
avatar.e_love_1,
avatar.e_love_2,
avatar.e_love_3,
avatar.e_love_4,
avatar.e_love_5,
avatar.e_love_6,
avatar.e_love_7,
avatar.e_love_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_confused_0,
avatar.e_confused_1,
avatar.e_confused_2,
avatar.e_confused_3,
avatar.e_confused_4,
avatar.e_confused_5,
avatar.e_confused_6,
avatar.e_confused_7,
avatar.e_confused_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_sleeping_0,
avatar.e_sleeping_1,
avatar.e_sleeping_2,
avatar.e_sleeping_3,
avatar.e_sleeping_4,
avatar.e_sleeping_5,
avatar.e_sleeping_6,
avatar.e_sleeping_7,
avatar.e_sleeping_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_wink_0,
avatar.e_wink_1,
avatar.e_wink_2,
avatar.e_wink_3,
avatar.e_wink_4,
avatar.e_wink_5,
avatar.e_wink_6,
avatar.e_wink_7,
avatar.e_wink_8,
],
);
// Query 2: Bulk resolve all UUIDs to paths
let paths: HashMap<Uuid, String> = if uuids.is_empty() {
HashMap::new()
} else {
sqlx::query_as::<_, (Uuid, String)>(
"SELECT id, prop_asset_path FROM auth.inventory WHERE id = ANY($1)",
)
.bind(&uuids)
.fetch_all(pool)
.await?
.into_iter()
.collect()
};
// Build the AvatarWithPaths
let resolve =
|uuid: Option<Uuid>| -> Option<String> { uuid.and_then(|id| paths.get(&id).cloned()) };
// Check if any UUID in the array is non-null (emotion is available)
let has_any = |slots: &[Option<Uuid>]| -> bool { slots.iter().any(|u| u.is_some()) };
// Compute emotions_available from UUID presence (not path resolution)
let emotions_available = [
has_any(&[
avatar.e_neutral_0,
avatar.e_neutral_1,
avatar.e_neutral_2,
avatar.e_neutral_3,
avatar.e_neutral_4,
avatar.e_neutral_5,
avatar.e_neutral_6,
avatar.e_neutral_7,
avatar.e_neutral_8,
]),
has_any(&[
avatar.e_happy_0,
avatar.e_happy_1,
avatar.e_happy_2,
avatar.e_happy_3,
avatar.e_happy_4,
avatar.e_happy_5,
avatar.e_happy_6,
avatar.e_happy_7,
avatar.e_happy_8,
]),
has_any(&[
avatar.e_sad_0,
avatar.e_sad_1,
avatar.e_sad_2,
avatar.e_sad_3,
avatar.e_sad_4,
avatar.e_sad_5,
avatar.e_sad_6,
avatar.e_sad_7,
avatar.e_sad_8,
]),
has_any(&[
avatar.e_angry_0,
avatar.e_angry_1,
avatar.e_angry_2,
avatar.e_angry_3,
avatar.e_angry_4,
avatar.e_angry_5,
avatar.e_angry_6,
avatar.e_angry_7,
avatar.e_angry_8,
]),
has_any(&[
avatar.e_surprised_0,
avatar.e_surprised_1,
avatar.e_surprised_2,
avatar.e_surprised_3,
avatar.e_surprised_4,
avatar.e_surprised_5,
avatar.e_surprised_6,
avatar.e_surprised_7,
avatar.e_surprised_8,
]),
has_any(&[
avatar.e_thinking_0,
avatar.e_thinking_1,
avatar.e_thinking_2,
avatar.e_thinking_3,
avatar.e_thinking_4,
avatar.e_thinking_5,
avatar.e_thinking_6,
avatar.e_thinking_7,
avatar.e_thinking_8,
]),
has_any(&[
avatar.e_laughing_0,
avatar.e_laughing_1,
avatar.e_laughing_2,
avatar.e_laughing_3,
avatar.e_laughing_4,
avatar.e_laughing_5,
avatar.e_laughing_6,
avatar.e_laughing_7,
avatar.e_laughing_8,
]),
has_any(&[
avatar.e_crying_0,
avatar.e_crying_1,
avatar.e_crying_2,
avatar.e_crying_3,
avatar.e_crying_4,
avatar.e_crying_5,
avatar.e_crying_6,
avatar.e_crying_7,
avatar.e_crying_8,
]),
has_any(&[
avatar.e_love_0,
avatar.e_love_1,
avatar.e_love_2,
avatar.e_love_3,
avatar.e_love_4,
avatar.e_love_5,
avatar.e_love_6,
avatar.e_love_7,
avatar.e_love_8,
]),
has_any(&[
avatar.e_confused_0,
avatar.e_confused_1,
avatar.e_confused_2,
avatar.e_confused_3,
avatar.e_confused_4,
avatar.e_confused_5,
avatar.e_confused_6,
avatar.e_confused_7,
avatar.e_confused_8,
]),
has_any(&[
avatar.e_sleeping_0,
avatar.e_sleeping_1,
avatar.e_sleeping_2,
avatar.e_sleeping_3,
avatar.e_sleeping_4,
avatar.e_sleeping_5,
avatar.e_sleeping_6,
avatar.e_sleeping_7,
avatar.e_sleeping_8,
]),
has_any(&[
avatar.e_wink_0,
avatar.e_wink_1,
avatar.e_wink_2,
avatar.e_wink_3,
avatar.e_wink_4,
avatar.e_wink_5,
avatar.e_wink_6,
avatar.e_wink_7,
avatar.e_wink_8,
]),
];
Ok(Some(AvatarWithPaths {
avatar_id: avatar.id,
current_emotion: avatar.current_emotion,
skin_layer: [
resolve(avatar.l_skin_0),
resolve(avatar.l_skin_1),
resolve(avatar.l_skin_2),
resolve(avatar.l_skin_3),
resolve(avatar.l_skin_4),
resolve(avatar.l_skin_5),
resolve(avatar.l_skin_6),
resolve(avatar.l_skin_7),
resolve(avatar.l_skin_8),
],
clothes_layer: [
resolve(avatar.l_clothes_0),
resolve(avatar.l_clothes_1),
resolve(avatar.l_clothes_2),
resolve(avatar.l_clothes_3),
resolve(avatar.l_clothes_4),
resolve(avatar.l_clothes_5),
resolve(avatar.l_clothes_6),
resolve(avatar.l_clothes_7),
resolve(avatar.l_clothes_8),
],
accessories_layer: [
resolve(avatar.l_accessories_0),
resolve(avatar.l_accessories_1),
resolve(avatar.l_accessories_2),
resolve(avatar.l_accessories_3),
resolve(avatar.l_accessories_4),
resolve(avatar.l_accessories_5),
resolve(avatar.l_accessories_6),
resolve(avatar.l_accessories_7),
resolve(avatar.l_accessories_8),
],
emotions: [
// Neutral (0)
[
resolve(avatar.e_neutral_0),
resolve(avatar.e_neutral_1),
resolve(avatar.e_neutral_2),
resolve(avatar.e_neutral_3),
resolve(avatar.e_neutral_4),
resolve(avatar.e_neutral_5),
resolve(avatar.e_neutral_6),
resolve(avatar.e_neutral_7),
resolve(avatar.e_neutral_8),
],
// Happy (1)
[
resolve(avatar.e_happy_0),
resolve(avatar.e_happy_1),
resolve(avatar.e_happy_2),
resolve(avatar.e_happy_3),
resolve(avatar.e_happy_4),
resolve(avatar.e_happy_5),
resolve(avatar.e_happy_6),
resolve(avatar.e_happy_7),
resolve(avatar.e_happy_8),
],
// Sad (2)
[
resolve(avatar.e_sad_0),
resolve(avatar.e_sad_1),
resolve(avatar.e_sad_2),
resolve(avatar.e_sad_3),
resolve(avatar.e_sad_4),
resolve(avatar.e_sad_5),
resolve(avatar.e_sad_6),
resolve(avatar.e_sad_7),
resolve(avatar.e_sad_8),
],
// Angry (3)
[
resolve(avatar.e_angry_0),
resolve(avatar.e_angry_1),
resolve(avatar.e_angry_2),
resolve(avatar.e_angry_3),
resolve(avatar.e_angry_4),
resolve(avatar.e_angry_5),
resolve(avatar.e_angry_6),
resolve(avatar.e_angry_7),
resolve(avatar.e_angry_8),
],
// Surprised (4)
[
resolve(avatar.e_surprised_0),
resolve(avatar.e_surprised_1),
resolve(avatar.e_surprised_2),
resolve(avatar.e_surprised_3),
resolve(avatar.e_surprised_4),
resolve(avatar.e_surprised_5),
resolve(avatar.e_surprised_6),
resolve(avatar.e_surprised_7),
resolve(avatar.e_surprised_8),
],
// Thinking (5)
[
resolve(avatar.e_thinking_0),
resolve(avatar.e_thinking_1),
resolve(avatar.e_thinking_2),
resolve(avatar.e_thinking_3),
resolve(avatar.e_thinking_4),
resolve(avatar.e_thinking_5),
resolve(avatar.e_thinking_6),
resolve(avatar.e_thinking_7),
resolve(avatar.e_thinking_8),
],
// Laughing (6)
[
resolve(avatar.e_laughing_0),
resolve(avatar.e_laughing_1),
resolve(avatar.e_laughing_2),
resolve(avatar.e_laughing_3),
resolve(avatar.e_laughing_4),
resolve(avatar.e_laughing_5),
resolve(avatar.e_laughing_6),
resolve(avatar.e_laughing_7),
resolve(avatar.e_laughing_8),
],
// Crying (7)
[
resolve(avatar.e_crying_0),
resolve(avatar.e_crying_1),
resolve(avatar.e_crying_2),
resolve(avatar.e_crying_3),
resolve(avatar.e_crying_4),
resolve(avatar.e_crying_5),
resolve(avatar.e_crying_6),
resolve(avatar.e_crying_7),
resolve(avatar.e_crying_8),
],
// Love (8)
[
resolve(avatar.e_love_0),
resolve(avatar.e_love_1),
resolve(avatar.e_love_2),
resolve(avatar.e_love_3),
resolve(avatar.e_love_4),
resolve(avatar.e_love_5),
resolve(avatar.e_love_6),
resolve(avatar.e_love_7),
resolve(avatar.e_love_8),
],
// Confused (9)
[
resolve(avatar.e_confused_0),
resolve(avatar.e_confused_1),
resolve(avatar.e_confused_2),
resolve(avatar.e_confused_3),
resolve(avatar.e_confused_4),
resolve(avatar.e_confused_5),
resolve(avatar.e_confused_6),
resolve(avatar.e_confused_7),
resolve(avatar.e_confused_8),
],
// Sleeping (10)
[
resolve(avatar.e_sleeping_0),
resolve(avatar.e_sleeping_1),
resolve(avatar.e_sleeping_2),
resolve(avatar.e_sleeping_3),
resolve(avatar.e_sleeping_4),
resolve(avatar.e_sleeping_5),
resolve(avatar.e_sleeping_6),
resolve(avatar.e_sleeping_7),
resolve(avatar.e_sleeping_8),
],
// Wink (11)
[
resolve(avatar.e_wink_0),
resolve(avatar.e_wink_1),
resolve(avatar.e_wink_2),
resolve(avatar.e_wink_3),
resolve(avatar.e_wink_4),
resolve(avatar.e_wink_5),
resolve(avatar.e_wink_6),
resolve(avatar.e_wink_7),
resolve(avatar.e_wink_8),
],
],
emotions_available,
}))
}
/// Get full avatar with all inventory UUIDs resolved to asset paths (connection variant).
///
/// This variant accepts a mutable connection reference for use with RLS-enabled connections.
pub async fn get_avatar_with_paths_conn(
conn: &mut PgConnection,
user_id: Uuid,
realm_id: Uuid,
) -> Result<Option<AvatarWithPaths>, AppError> {
// Query 1: Get the avatar row with current_emotion from active_avatars
let avatar_row = sqlx::query_as::<_, AvatarWithEmotion>(
r#"
SELECT
a.*,
aa.current_emotion
FROM auth.active_avatars aa
JOIN auth.avatars a ON aa.avatar_id = a.id
WHERE aa.user_id = $1 AND aa.realm_id = $2
"#,
)
.bind(user_id)
.bind(realm_id)
.fetch_optional(&mut *conn)
.await?;
let Some(avatar) = avatar_row else {
return Ok(None);
};
// Collect all non-null UUIDs from the avatar slots
let mut uuids: Vec<Uuid> = Vec::new();
// Content layers
collect_uuids(
&mut uuids,
&[
avatar.l_skin_0,
avatar.l_skin_1,
avatar.l_skin_2,
avatar.l_skin_3,
avatar.l_skin_4,
avatar.l_skin_5,
avatar.l_skin_6,
avatar.l_skin_7,
avatar.l_skin_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.l_clothes_0,
avatar.l_clothes_1,
avatar.l_clothes_2,
avatar.l_clothes_3,
avatar.l_clothes_4,
avatar.l_clothes_5,
avatar.l_clothes_6,
avatar.l_clothes_7,
avatar.l_clothes_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.l_accessories_0,
avatar.l_accessories_1,
avatar.l_accessories_2,
avatar.l_accessories_3,
avatar.l_accessories_4,
avatar.l_accessories_5,
avatar.l_accessories_6,
avatar.l_accessories_7,
avatar.l_accessories_8,
],
);
// Emotion layers (12 emotions × 9 positions)
collect_uuids(
&mut uuids,
&[
avatar.e_neutral_0,
avatar.e_neutral_1,
avatar.e_neutral_2,
avatar.e_neutral_3,
avatar.e_neutral_4,
avatar.e_neutral_5,
avatar.e_neutral_6,
avatar.e_neutral_7,
avatar.e_neutral_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_happy_0,
avatar.e_happy_1,
avatar.e_happy_2,
avatar.e_happy_3,
avatar.e_happy_4,
avatar.e_happy_5,
avatar.e_happy_6,
avatar.e_happy_7,
avatar.e_happy_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_sad_0,
avatar.e_sad_1,
avatar.e_sad_2,
avatar.e_sad_3,
avatar.e_sad_4,
avatar.e_sad_5,
avatar.e_sad_6,
avatar.e_sad_7,
avatar.e_sad_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_angry_0,
avatar.e_angry_1,
avatar.e_angry_2,
avatar.e_angry_3,
avatar.e_angry_4,
avatar.e_angry_5,
avatar.e_angry_6,
avatar.e_angry_7,
avatar.e_angry_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_surprised_0,
avatar.e_surprised_1,
avatar.e_surprised_2,
avatar.e_surprised_3,
avatar.e_surprised_4,
avatar.e_surprised_5,
avatar.e_surprised_6,
avatar.e_surprised_7,
avatar.e_surprised_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_thinking_0,
avatar.e_thinking_1,
avatar.e_thinking_2,
avatar.e_thinking_3,
avatar.e_thinking_4,
avatar.e_thinking_5,
avatar.e_thinking_6,
avatar.e_thinking_7,
avatar.e_thinking_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_laughing_0,
avatar.e_laughing_1,
avatar.e_laughing_2,
avatar.e_laughing_3,
avatar.e_laughing_4,
avatar.e_laughing_5,
avatar.e_laughing_6,
avatar.e_laughing_7,
avatar.e_laughing_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_crying_0,
avatar.e_crying_1,
avatar.e_crying_2,
avatar.e_crying_3,
avatar.e_crying_4,
avatar.e_crying_5,
avatar.e_crying_6,
avatar.e_crying_7,
avatar.e_crying_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_love_0,
avatar.e_love_1,
avatar.e_love_2,
avatar.e_love_3,
avatar.e_love_4,
avatar.e_love_5,
avatar.e_love_6,
avatar.e_love_7,
avatar.e_love_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_confused_0,
avatar.e_confused_1,
avatar.e_confused_2,
avatar.e_confused_3,
avatar.e_confused_4,
avatar.e_confused_5,
avatar.e_confused_6,
avatar.e_confused_7,
avatar.e_confused_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_sleeping_0,
avatar.e_sleeping_1,
avatar.e_sleeping_2,
avatar.e_sleeping_3,
avatar.e_sleeping_4,
avatar.e_sleeping_5,
avatar.e_sleeping_6,
avatar.e_sleeping_7,
avatar.e_sleeping_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.e_wink_0,
avatar.e_wink_1,
avatar.e_wink_2,
avatar.e_wink_3,
avatar.e_wink_4,
avatar.e_wink_5,
avatar.e_wink_6,
avatar.e_wink_7,
avatar.e_wink_8,
],
);
// Query 2: Bulk resolve all UUIDs to paths
let paths: HashMap<Uuid, String> = if uuids.is_empty() {
HashMap::new()
} else {
sqlx::query_as::<_, (Uuid, String)>(
"SELECT id, prop_asset_path FROM auth.inventory WHERE id = ANY($1)",
)
.bind(&uuids)
.fetch_all(&mut *conn)
.await?
.into_iter()
.collect()
};
// Build the AvatarWithPaths
let resolve =
|uuid: Option<Uuid>| -> Option<String> { uuid.and_then(|id| paths.get(&id).cloned()) };
// Check if any UUID in the array is non-null (emotion is available)
let has_any = |slots: &[Option<Uuid>]| -> bool { slots.iter().any(|u| u.is_some()) };
// Compute emotions_available from UUID presence (not path resolution)
let emotions_available = [
has_any(&[
avatar.e_neutral_0,
avatar.e_neutral_1,
avatar.e_neutral_2,
avatar.e_neutral_3,
avatar.e_neutral_4,
avatar.e_neutral_5,
avatar.e_neutral_6,
avatar.e_neutral_7,
avatar.e_neutral_8,
]),
has_any(&[
avatar.e_happy_0,
avatar.e_happy_1,
avatar.e_happy_2,
avatar.e_happy_3,
avatar.e_happy_4,
avatar.e_happy_5,
avatar.e_happy_6,
avatar.e_happy_7,
avatar.e_happy_8,
]),
has_any(&[
avatar.e_sad_0,
avatar.e_sad_1,
avatar.e_sad_2,
avatar.e_sad_3,
avatar.e_sad_4,
avatar.e_sad_5,
avatar.e_sad_6,
avatar.e_sad_7,
avatar.e_sad_8,
]),
has_any(&[
avatar.e_angry_0,
avatar.e_angry_1,
avatar.e_angry_2,
avatar.e_angry_3,
avatar.e_angry_4,
avatar.e_angry_5,
avatar.e_angry_6,
avatar.e_angry_7,
avatar.e_angry_8,
]),
has_any(&[
avatar.e_surprised_0,
avatar.e_surprised_1,
avatar.e_surprised_2,
avatar.e_surprised_3,
avatar.e_surprised_4,
avatar.e_surprised_5,
avatar.e_surprised_6,
avatar.e_surprised_7,
avatar.e_surprised_8,
]),
has_any(&[
avatar.e_thinking_0,
avatar.e_thinking_1,
avatar.e_thinking_2,
avatar.e_thinking_3,
avatar.e_thinking_4,
avatar.e_thinking_5,
avatar.e_thinking_6,
avatar.e_thinking_7,
avatar.e_thinking_8,
]),
has_any(&[
avatar.e_laughing_0,
avatar.e_laughing_1,
avatar.e_laughing_2,
avatar.e_laughing_3,
avatar.e_laughing_4,
avatar.e_laughing_5,
avatar.e_laughing_6,
avatar.e_laughing_7,
avatar.e_laughing_8,
]),
has_any(&[
avatar.e_crying_0,
avatar.e_crying_1,
avatar.e_crying_2,
avatar.e_crying_3,
avatar.e_crying_4,
avatar.e_crying_5,
avatar.e_crying_6,
avatar.e_crying_7,
avatar.e_crying_8,
]),
has_any(&[
avatar.e_love_0,
avatar.e_love_1,
avatar.e_love_2,
avatar.e_love_3,
avatar.e_love_4,
avatar.e_love_5,
avatar.e_love_6,
avatar.e_love_7,
avatar.e_love_8,
]),
has_any(&[
avatar.e_confused_0,
avatar.e_confused_1,
avatar.e_confused_2,
avatar.e_confused_3,
avatar.e_confused_4,
avatar.e_confused_5,
avatar.e_confused_6,
avatar.e_confused_7,
avatar.e_confused_8,
]),
has_any(&[
avatar.e_sleeping_0,
avatar.e_sleeping_1,
avatar.e_sleeping_2,
avatar.e_sleeping_3,
avatar.e_sleeping_4,
avatar.e_sleeping_5,
avatar.e_sleeping_6,
avatar.e_sleeping_7,
avatar.e_sleeping_8,
]),
has_any(&[
avatar.e_wink_0,
avatar.e_wink_1,
avatar.e_wink_2,
avatar.e_wink_3,
avatar.e_wink_4,
avatar.e_wink_5,
avatar.e_wink_6,
avatar.e_wink_7,
avatar.e_wink_8,
]),
];
Ok(Some(AvatarWithPaths {
avatar_id: avatar.id,
current_emotion: avatar.current_emotion,
skin_layer: [
resolve(avatar.l_skin_0),
resolve(avatar.l_skin_1),
resolve(avatar.l_skin_2),
resolve(avatar.l_skin_3),
resolve(avatar.l_skin_4),
resolve(avatar.l_skin_5),
resolve(avatar.l_skin_6),
resolve(avatar.l_skin_7),
resolve(avatar.l_skin_8),
],
clothes_layer: [
resolve(avatar.l_clothes_0),
resolve(avatar.l_clothes_1),
resolve(avatar.l_clothes_2),
resolve(avatar.l_clothes_3),
resolve(avatar.l_clothes_4),
resolve(avatar.l_clothes_5),
resolve(avatar.l_clothes_6),
resolve(avatar.l_clothes_7),
resolve(avatar.l_clothes_8),
],
accessories_layer: [
resolve(avatar.l_accessories_0),
resolve(avatar.l_accessories_1),
resolve(avatar.l_accessories_2),
resolve(avatar.l_accessories_3),
resolve(avatar.l_accessories_4),
resolve(avatar.l_accessories_5),
resolve(avatar.l_accessories_6),
resolve(avatar.l_accessories_7),
resolve(avatar.l_accessories_8),
],
emotions: [
// Neutral (0)
[
resolve(avatar.e_neutral_0),
resolve(avatar.e_neutral_1),
resolve(avatar.e_neutral_2),
resolve(avatar.e_neutral_3),
resolve(avatar.e_neutral_4),
resolve(avatar.e_neutral_5),
resolve(avatar.e_neutral_6),
resolve(avatar.e_neutral_7),
resolve(avatar.e_neutral_8),
],
// Happy (1)
[
resolve(avatar.e_happy_0),
resolve(avatar.e_happy_1),
resolve(avatar.e_happy_2),
resolve(avatar.e_happy_3),
resolve(avatar.e_happy_4),
resolve(avatar.e_happy_5),
resolve(avatar.e_happy_6),
resolve(avatar.e_happy_7),
resolve(avatar.e_happy_8),
],
// Sad (2)
[
resolve(avatar.e_sad_0),
resolve(avatar.e_sad_1),
resolve(avatar.e_sad_2),
resolve(avatar.e_sad_3),
resolve(avatar.e_sad_4),
resolve(avatar.e_sad_5),
resolve(avatar.e_sad_6),
resolve(avatar.e_sad_7),
resolve(avatar.e_sad_8),
],
// Angry (3)
[
resolve(avatar.e_angry_0),
resolve(avatar.e_angry_1),
resolve(avatar.e_angry_2),
resolve(avatar.e_angry_3),
resolve(avatar.e_angry_4),
resolve(avatar.e_angry_5),
resolve(avatar.e_angry_6),
resolve(avatar.e_angry_7),
resolve(avatar.e_angry_8),
],
// Surprised (4)
[
resolve(avatar.e_surprised_0),
resolve(avatar.e_surprised_1),
resolve(avatar.e_surprised_2),
resolve(avatar.e_surprised_3),
resolve(avatar.e_surprised_4),
resolve(avatar.e_surprised_5),
resolve(avatar.e_surprised_6),
resolve(avatar.e_surprised_7),
resolve(avatar.e_surprised_8),
],
// Thinking (5)
[
resolve(avatar.e_thinking_0),
resolve(avatar.e_thinking_1),
resolve(avatar.e_thinking_2),
resolve(avatar.e_thinking_3),
resolve(avatar.e_thinking_4),
resolve(avatar.e_thinking_5),
resolve(avatar.e_thinking_6),
resolve(avatar.e_thinking_7),
resolve(avatar.e_thinking_8),
],
// Laughing (6)
[
resolve(avatar.e_laughing_0),
resolve(avatar.e_laughing_1),
resolve(avatar.e_laughing_2),
resolve(avatar.e_laughing_3),
resolve(avatar.e_laughing_4),
resolve(avatar.e_laughing_5),
resolve(avatar.e_laughing_6),
resolve(avatar.e_laughing_7),
resolve(avatar.e_laughing_8),
],
// Crying (7)
[
resolve(avatar.e_crying_0),
resolve(avatar.e_crying_1),
resolve(avatar.e_crying_2),
resolve(avatar.e_crying_3),
resolve(avatar.e_crying_4),
resolve(avatar.e_crying_5),
resolve(avatar.e_crying_6),
resolve(avatar.e_crying_7),
resolve(avatar.e_crying_8),
],
// Love (8)
[
resolve(avatar.e_love_0),
resolve(avatar.e_love_1),
resolve(avatar.e_love_2),
resolve(avatar.e_love_3),
resolve(avatar.e_love_4),
resolve(avatar.e_love_5),
resolve(avatar.e_love_6),
resolve(avatar.e_love_7),
resolve(avatar.e_love_8),
],
// Confused (9)
[
resolve(avatar.e_confused_0),
resolve(avatar.e_confused_1),
resolve(avatar.e_confused_2),
resolve(avatar.e_confused_3),
resolve(avatar.e_confused_4),
resolve(avatar.e_confused_5),
resolve(avatar.e_confused_6),
resolve(avatar.e_confused_7),
resolve(avatar.e_confused_8),
],
// Sleeping (10)
[
resolve(avatar.e_sleeping_0),
resolve(avatar.e_sleeping_1),
resolve(avatar.e_sleeping_2),
resolve(avatar.e_sleeping_3),
resolve(avatar.e_sleeping_4),
resolve(avatar.e_sleeping_5),
resolve(avatar.e_sleeping_6),
resolve(avatar.e_sleeping_7),
resolve(avatar.e_sleeping_8),
],
// Wink (11)
[
resolve(avatar.e_wink_0),
resolve(avatar.e_wink_1),
resolve(avatar.e_wink_2),
resolve(avatar.e_wink_3),
resolve(avatar.e_wink_4),
resolve(avatar.e_wink_5),
resolve(avatar.e_wink_6),
resolve(avatar.e_wink_7),
resolve(avatar.e_wink_8),
],
],
emotions_available,
}))
}
/// Helper to collect non-null UUIDs into a Vec.
fn collect_uuids(dest: &mut Vec<Uuid>, sources: &[Option<Uuid>]) {
for uuid in sources {
if let Some(id) = uuid {
dest.push(*id);
}
}
}
/// Avatar row with current_emotion from active_avatars join.
#[derive(Debug, sqlx::FromRow)]
struct AvatarWithEmotion {
pub id: Uuid,
pub current_emotion: EmotionState,
// Content layers
pub l_skin_0: Option<Uuid>,
pub l_skin_1: Option<Uuid>,
pub l_skin_2: Option<Uuid>,
pub l_skin_3: Option<Uuid>,
pub l_skin_4: Option<Uuid>,
pub l_skin_5: Option<Uuid>,
pub l_skin_6: Option<Uuid>,
pub l_skin_7: Option<Uuid>,
pub l_skin_8: Option<Uuid>,
pub l_clothes_0: Option<Uuid>,
pub l_clothes_1: Option<Uuid>,
pub l_clothes_2: Option<Uuid>,
pub l_clothes_3: Option<Uuid>,
pub l_clothes_4: Option<Uuid>,
pub l_clothes_5: Option<Uuid>,
pub l_clothes_6: Option<Uuid>,
pub l_clothes_7: Option<Uuid>,
pub l_clothes_8: Option<Uuid>,
pub l_accessories_0: Option<Uuid>,
pub l_accessories_1: Option<Uuid>,
pub l_accessories_2: Option<Uuid>,
pub l_accessories_3: Option<Uuid>,
pub l_accessories_4: Option<Uuid>,
pub l_accessories_5: Option<Uuid>,
pub l_accessories_6: Option<Uuid>,
pub l_accessories_7: Option<Uuid>,
pub l_accessories_8: Option<Uuid>,
// Emotion layers
pub e_neutral_0: Option<Uuid>,
pub e_neutral_1: Option<Uuid>,
pub e_neutral_2: Option<Uuid>,
pub e_neutral_3: Option<Uuid>,
pub e_neutral_4: Option<Uuid>,
pub e_neutral_5: Option<Uuid>,
pub e_neutral_6: Option<Uuid>,
pub e_neutral_7: Option<Uuid>,
pub e_neutral_8: Option<Uuid>,
pub e_happy_0: Option<Uuid>,
pub e_happy_1: Option<Uuid>,
pub e_happy_2: Option<Uuid>,
pub e_happy_3: Option<Uuid>,
pub e_happy_4: Option<Uuid>,
pub e_happy_5: Option<Uuid>,
pub e_happy_6: Option<Uuid>,
pub e_happy_7: Option<Uuid>,
pub e_happy_8: Option<Uuid>,
pub e_sad_0: Option<Uuid>,
pub e_sad_1: Option<Uuid>,
pub e_sad_2: Option<Uuid>,
pub e_sad_3: Option<Uuid>,
pub e_sad_4: Option<Uuid>,
pub e_sad_5: Option<Uuid>,
pub e_sad_6: Option<Uuid>,
pub e_sad_7: Option<Uuid>,
pub e_sad_8: Option<Uuid>,
pub e_angry_0: Option<Uuid>,
pub e_angry_1: Option<Uuid>,
pub e_angry_2: Option<Uuid>,
pub e_angry_3: Option<Uuid>,
pub e_angry_4: Option<Uuid>,
pub e_angry_5: Option<Uuid>,
pub e_angry_6: Option<Uuid>,
pub e_angry_7: Option<Uuid>,
pub e_angry_8: Option<Uuid>,
pub e_surprised_0: Option<Uuid>,
pub e_surprised_1: Option<Uuid>,
pub e_surprised_2: Option<Uuid>,
pub e_surprised_3: Option<Uuid>,
pub e_surprised_4: Option<Uuid>,
pub e_surprised_5: Option<Uuid>,
pub e_surprised_6: Option<Uuid>,
pub e_surprised_7: Option<Uuid>,
pub e_surprised_8: Option<Uuid>,
pub e_thinking_0: Option<Uuid>,
pub e_thinking_1: Option<Uuid>,
pub e_thinking_2: Option<Uuid>,
pub e_thinking_3: Option<Uuid>,
pub e_thinking_4: Option<Uuid>,
pub e_thinking_5: Option<Uuid>,
pub e_thinking_6: Option<Uuid>,
pub e_thinking_7: Option<Uuid>,
pub e_thinking_8: Option<Uuid>,
pub e_laughing_0: Option<Uuid>,
pub e_laughing_1: Option<Uuid>,
pub e_laughing_2: Option<Uuid>,
pub e_laughing_3: Option<Uuid>,
pub e_laughing_4: Option<Uuid>,
pub e_laughing_5: Option<Uuid>,
pub e_laughing_6: Option<Uuid>,
pub e_laughing_7: Option<Uuid>,
pub e_laughing_8: Option<Uuid>,
pub e_crying_0: Option<Uuid>,
pub e_crying_1: Option<Uuid>,
pub e_crying_2: Option<Uuid>,
pub e_crying_3: Option<Uuid>,
pub e_crying_4: Option<Uuid>,
pub e_crying_5: Option<Uuid>,
pub e_crying_6: Option<Uuid>,
pub e_crying_7: Option<Uuid>,
pub e_crying_8: Option<Uuid>,
pub e_love_0: Option<Uuid>,
pub e_love_1: Option<Uuid>,
pub e_love_2: Option<Uuid>,
pub e_love_3: Option<Uuid>,
pub e_love_4: Option<Uuid>,
pub e_love_5: Option<Uuid>,
pub e_love_6: Option<Uuid>,
pub e_love_7: Option<Uuid>,
pub e_love_8: Option<Uuid>,
pub e_confused_0: Option<Uuid>,
pub e_confused_1: Option<Uuid>,
pub e_confused_2: Option<Uuid>,
pub e_confused_3: Option<Uuid>,
pub e_confused_4: Option<Uuid>,
pub e_confused_5: Option<Uuid>,
pub e_confused_6: Option<Uuid>,
pub e_confused_7: Option<Uuid>,
pub e_confused_8: Option<Uuid>,
pub e_sleeping_0: Option<Uuid>,
pub e_sleeping_1: Option<Uuid>,
pub e_sleeping_2: Option<Uuid>,
pub e_sleeping_3: Option<Uuid>,
pub e_sleeping_4: Option<Uuid>,
pub e_sleeping_5: Option<Uuid>,
pub e_sleeping_6: Option<Uuid>,
pub e_sleeping_7: Option<Uuid>,
pub e_sleeping_8: Option<Uuid>,
pub e_wink_0: Option<Uuid>,
pub e_wink_1: Option<Uuid>,
pub e_wink_2: Option<Uuid>,
pub e_wink_3: Option<Uuid>,
pub e_wink_4: Option<Uuid>,
pub e_wink_5: Option<Uuid>,
pub e_wink_6: Option<Uuid>,
pub e_wink_7: Option<Uuid>,
pub e_wink_8: Option<Uuid>,
}
/// Set the current emotion for a user (simplified - no path lookup).
///
/// This is used when the user's client has the full avatar cached locally
/// and can render the emotion from its local cache.
pub async fn set_emotion_simple<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
emotion: EmotionState,
) -> Result<(), AppError> {
let result = sqlx::query(
r#"
UPDATE auth.active_avatars
SET current_emotion = $3::server.emotion_state, updated_at = now()
WHERE user_id = $1 AND realm_id = $2
"#,
)
.bind(user_id)
.bind(realm_id)
.bind(emotion.to_string())
.execute(executor)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(
"No active avatar for this user in this realm".to_string(),
));
}
Ok(())
}
/// Update an avatar slot by assigning an inventory item to it.
///
/// The column_name should be one of:
/// - "l_skin_0" through "l_skin_8"
/// - "l_clothes_0" through "l_clothes_8"
/// - "l_accessories_0" through "l_accessories_8"
/// - "e_{emotion}_0" through "e_{emotion}_8" (e.g., "e_happy_4")
pub async fn update_avatar_slot(
conn: &mut PgConnection,
user_id: Uuid,
realm_id: Uuid,
column_name: &str,
inventory_id: Option<Uuid>,
) -> Result<(), AppError> {
// Validate column name format to prevent SQL injection
let valid_prefixes = [
"l_skin_",
"l_clothes_",
"l_accessories_",
"e_neutral_",
"e_happy_",
"e_sad_",
"e_angry_",
"e_surprised_",
"e_thinking_",
"e_laughing_",
"e_crying_",
"e_love_",
"e_confused_",
"e_sleeping_",
"e_wink_",
];
let is_valid = valid_prefixes
.iter()
.any(|prefix| column_name.starts_with(prefix))
&& column_name
.chars()
.last()
.map(|c| c.is_ascii_digit() && c <= '8')
.unwrap_or(false);
if !is_valid {
return Err(AppError::Validation(format!(
"Invalid column name: {}",
column_name
)));
}
// Build dynamic UPDATE query
// Note: We've validated the column name format above to prevent SQL injection
let query = format!(
r#"
UPDATE auth.avatars
SET {} = $3, updated_at = now()
WHERE id = (
SELECT avatar_id FROM auth.active_avatars
WHERE user_id = $1 AND realm_id = $2
)
"#,
column_name
);
let result = sqlx::query(&query)
.bind(user_id)
.bind(realm_id)
.bind(inventory_id)
.execute(&mut *conn)
.await?;
if result.rows_affected() == 0 {
return Err(AppError::NotFound(
"No active avatar for this user in this realm".to_string(),
));
}
Ok(())
}
/// Data needed to resolve effective avatar for a user.
#[derive(Debug, sqlx::FromRow)]
pub struct AvatarResolutionContext {
// Active avatar row data
pub avatar_id: Option<Uuid>,
pub selected_server_avatar_id: Option<Uuid>,
pub selected_realm_avatar_id: Option<Uuid>,
pub current_emotion: EmotionState,
// Forced avatar data
pub forced_avatar_id: Option<Uuid>,
pub forced_avatar_source: Option<String>,
pub forced_until: Option<chrono::DateTime<chrono::Utc>>,
// User preferences
pub gender_preference: crate::models::GenderPreference,
pub age_category: crate::models::AgeCategory,
}
/// Source of the resolved avatar.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AvatarSource {
/// User's custom avatar from auth.avatars
Custom,
/// User-selected realm avatar from avatar store
SelectedRealm,
/// User-selected server avatar from avatar store
SelectedServer,
/// Realm default avatar based on gender/age
RealmDefault,
/// Server default avatar based on gender/age
ServerDefault,
/// Forced avatar (mod command or scene)
Forced,
}
/// Get the effective avatar render data for a user in a realm.
///
/// This function implements the avatar resolution priority chain:
/// 1. Forced avatar (mod command or scene) - highest priority
/// 2. User's custom avatar (auth.avatars via avatar_id)
/// 3. User-selected realm avatar (selected_realm_avatar_id)
/// 4. User-selected server avatar (selected_server_avatar_id)
/// 5. Realm default (based on gender+age)
/// 6. Server default (based on gender+age) - lowest priority
pub async fn get_effective_avatar_render_data<'e>(
executor: impl PgExecutor<'e> + Copy,
user_id: Uuid,
realm_id: Uuid,
) -> Result<Option<(crate::models::AvatarRenderData, AvatarSource)>, AppError> {
// Get the resolution context with all necessary data
// Use LEFT JOIN so we can still get user preferences even without an active_avatars entry
let ctx = sqlx::query_as::<_, AvatarResolutionContext>(
r#"
SELECT
aa.avatar_id,
aa.selected_server_avatar_id,
aa.selected_realm_avatar_id,
COALESCE(aa.current_emotion, 'happy'::server.emotion_state) as current_emotion,
aa.forced_avatar_id,
aa.forced_avatar_source,
aa.forced_until,
u.gender_preference,
u.age_category
FROM auth.users u
LEFT JOIN auth.active_avatars aa ON aa.user_id = u.id AND aa.realm_id = $2
WHERE u.id = $1
"#,
)
.bind(user_id)
.bind(realm_id)
.fetch_optional(executor)
.await?;
let Some(ctx) = ctx else {
// User doesn't exist
return Ok(None);
};
// Priority 1: Check for forced avatar (not expired)
if let Some(forced_id) = ctx.forced_avatar_id {
let is_expired = ctx.forced_until.map(|t| t < chrono::Utc::now()).unwrap_or(false);
if !is_expired {
if let Some(source) = &ctx.forced_avatar_source {
match source.as_str() {
"server" | "scene" => {
// Resolve from server.avatars
if let Some(avatar) = super::server_avatars::get_server_avatar_by_id(executor, forced_id).await? {
let render = super::server_avatars::resolve_server_avatar_to_render_data(
executor, &avatar, ctx.current_emotion
).await?;
return Ok(Some((render, AvatarSource::Forced)));
}
}
"realm" => {
// Resolve from realm.avatars
if let Some(avatar) = super::realm_avatars::get_realm_avatar_by_id(executor, forced_id).await? {
let render = super::realm_avatars::resolve_realm_avatar_to_render_data(
executor, &avatar, ctx.current_emotion
).await?;
return Ok(Some((render, AvatarSource::Forced)));
}
}
_ => {}
}
}
}
}
// Priority 2: User's custom avatar
if let Some(avatar_id) = ctx.avatar_id {
if let Some(render) = resolve_user_avatar_to_render_data(executor, avatar_id, ctx.current_emotion).await? {
return Ok(Some((render, AvatarSource::Custom)));
}
}
// Priority 3: User-selected realm avatar
if let Some(realm_avatar_id) = ctx.selected_realm_avatar_id {
if let Some(avatar) = super::realm_avatars::get_realm_avatar_by_id(executor, realm_avatar_id).await? {
let render = super::realm_avatars::resolve_realm_avatar_to_render_data(
executor, &avatar, ctx.current_emotion
).await?;
return Ok(Some((render, AvatarSource::SelectedRealm)));
}
}
// Priority 4: User-selected server avatar
if let Some(server_avatar_id) = ctx.selected_server_avatar_id {
if let Some(avatar) = super::server_avatars::get_server_avatar_by_id(executor, server_avatar_id).await? {
let render = super::server_avatars::resolve_server_avatar_to_render_data(
executor, &avatar, ctx.current_emotion
).await?;
return Ok(Some((render, AvatarSource::SelectedServer)));
}
}
// Priority 5: Realm default avatar (based on gender+age)
let realm_default_id = get_realm_default_avatar_id(executor, realm_id, ctx.gender_preference, ctx.age_category).await?;
if let Some(avatar_id) = realm_default_id {
if let Some(avatar) = super::realm_avatars::get_realm_avatar_by_id(executor, avatar_id).await? {
let render = super::realm_avatars::resolve_realm_avatar_to_render_data(
executor, &avatar, ctx.current_emotion
).await?;
return Ok(Some((render, AvatarSource::RealmDefault)));
}
}
// Priority 6: Server default avatar (based on gender+age)
let server_default_id = get_server_default_avatar_id(executor, ctx.gender_preference, ctx.age_category).await?;
if let Some(avatar_id) = server_default_id {
if let Some(avatar) = super::server_avatars::get_server_avatar_by_id(executor, avatar_id).await? {
let render = super::server_avatars::resolve_server_avatar_to_render_data(
executor, &avatar, ctx.current_emotion
).await?;
return Ok(Some((render, AvatarSource::ServerDefault)));
}
}
Ok(None)
}
/// Resolve a user's custom avatar (from auth.avatars) to render data.
async fn resolve_user_avatar_to_render_data<'e>(
executor: impl PgExecutor<'e> + Copy,
avatar_id: Uuid,
current_emotion: EmotionState,
) -> Result<Option<crate::models::AvatarRenderData>, AppError> {
// Get the avatar with inventory joins
let avatar = sqlx::query_as::<_, AvatarWithEmotion>(
r#"
SELECT
a.id, $2::server.emotion_state as current_emotion,
a.l_skin_0, a.l_skin_1, a.l_skin_2, a.l_skin_3, a.l_skin_4,
a.l_skin_5, a.l_skin_6, a.l_skin_7, a.l_skin_8,
a.l_clothes_0, a.l_clothes_1, a.l_clothes_2, a.l_clothes_3, a.l_clothes_4,
a.l_clothes_5, a.l_clothes_6, a.l_clothes_7, a.l_clothes_8,
a.l_accessories_0, a.l_accessories_1, a.l_accessories_2, a.l_accessories_3, a.l_accessories_4,
a.l_accessories_5, a.l_accessories_6, a.l_accessories_7, a.l_accessories_8,
a.e_neutral_0, a.e_neutral_1, a.e_neutral_2, a.e_neutral_3, a.e_neutral_4,
a.e_neutral_5, a.e_neutral_6, a.e_neutral_7, a.e_neutral_8,
a.e_happy_0, a.e_happy_1, a.e_happy_2, a.e_happy_3, a.e_happy_4,
a.e_happy_5, a.e_happy_6, a.e_happy_7, a.e_happy_8,
a.e_sad_0, a.e_sad_1, a.e_sad_2, a.e_sad_3, a.e_sad_4,
a.e_sad_5, a.e_sad_6, a.e_sad_7, a.e_sad_8,
a.e_angry_0, a.e_angry_1, a.e_angry_2, a.e_angry_3, a.e_angry_4,
a.e_angry_5, a.e_angry_6, a.e_angry_7, a.e_angry_8,
a.e_surprised_0, a.e_surprised_1, a.e_surprised_2, a.e_surprised_3, a.e_surprised_4,
a.e_surprised_5, a.e_surprised_6, a.e_surprised_7, a.e_surprised_8,
a.e_thinking_0, a.e_thinking_1, a.e_thinking_2, a.e_thinking_3, a.e_thinking_4,
a.e_thinking_5, a.e_thinking_6, a.e_thinking_7, a.e_thinking_8,
a.e_laughing_0, a.e_laughing_1, a.e_laughing_2, a.e_laughing_3, a.e_laughing_4,
a.e_laughing_5, a.e_laughing_6, a.e_laughing_7, a.e_laughing_8,
a.e_crying_0, a.e_crying_1, a.e_crying_2, a.e_crying_3, a.e_crying_4,
a.e_crying_5, a.e_crying_6, a.e_crying_7, a.e_crying_8,
a.e_love_0, a.e_love_1, a.e_love_2, a.e_love_3, a.e_love_4,
a.e_love_5, a.e_love_6, a.e_love_7, a.e_love_8,
a.e_confused_0, a.e_confused_1, a.e_confused_2, a.e_confused_3, a.e_confused_4,
a.e_confused_5, a.e_confused_6, a.e_confused_7, a.e_confused_8,
a.e_sleeping_0, a.e_sleeping_1, a.e_sleeping_2, a.e_sleeping_3, a.e_sleeping_4,
a.e_sleeping_5, a.e_sleeping_6, a.e_sleeping_7, a.e_sleeping_8,
a.e_wink_0, a.e_wink_1, a.e_wink_2, a.e_wink_3, a.e_wink_4,
a.e_wink_5, a.e_wink_6, a.e_wink_7, a.e_wink_8
FROM auth.avatars a
WHERE a.id = $1
"#,
)
.bind(avatar_id)
.bind(current_emotion)
.fetch_optional(executor)
.await?;
let Some(avatar) = avatar else {
return Ok(None);
};
// Collect all inventory UUIDs
let mut uuids: Vec<Uuid> = Vec::new();
collect_uuids(
&mut uuids,
&[
avatar.l_skin_0, avatar.l_skin_1, avatar.l_skin_2,
avatar.l_skin_3, avatar.l_skin_4, avatar.l_skin_5,
avatar.l_skin_6, avatar.l_skin_7, avatar.l_skin_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.l_clothes_0, avatar.l_clothes_1, avatar.l_clothes_2,
avatar.l_clothes_3, avatar.l_clothes_4, avatar.l_clothes_5,
avatar.l_clothes_6, avatar.l_clothes_7, avatar.l_clothes_8,
],
);
collect_uuids(
&mut uuids,
&[
avatar.l_accessories_0, avatar.l_accessories_1, avatar.l_accessories_2,
avatar.l_accessories_3, avatar.l_accessories_4, avatar.l_accessories_5,
avatar.l_accessories_6, avatar.l_accessories_7, avatar.l_accessories_8,
],
);
// Get emotion slots for current emotion
let emotion_slots: [Option<Uuid>; 9] = match current_emotion {
EmotionState::Neutral => [avatar.e_neutral_0, avatar.e_neutral_1, avatar.e_neutral_2,
avatar.e_neutral_3, avatar.e_neutral_4, avatar.e_neutral_5,
avatar.e_neutral_6, avatar.e_neutral_7, avatar.e_neutral_8],
EmotionState::Happy => [avatar.e_happy_0, avatar.e_happy_1, avatar.e_happy_2,
avatar.e_happy_3, avatar.e_happy_4, avatar.e_happy_5,
avatar.e_happy_6, avatar.e_happy_7, avatar.e_happy_8],
EmotionState::Sad => [avatar.e_sad_0, avatar.e_sad_1, avatar.e_sad_2,
avatar.e_sad_3, avatar.e_sad_4, avatar.e_sad_5,
avatar.e_sad_6, avatar.e_sad_7, avatar.e_sad_8],
EmotionState::Angry => [avatar.e_angry_0, avatar.e_angry_1, avatar.e_angry_2,
avatar.e_angry_3, avatar.e_angry_4, avatar.e_angry_5,
avatar.e_angry_6, avatar.e_angry_7, avatar.e_angry_8],
EmotionState::Surprised => [avatar.e_surprised_0, avatar.e_surprised_1, avatar.e_surprised_2,
avatar.e_surprised_3, avatar.e_surprised_4, avatar.e_surprised_5,
avatar.e_surprised_6, avatar.e_surprised_7, avatar.e_surprised_8],
EmotionState::Thinking => [avatar.e_thinking_0, avatar.e_thinking_1, avatar.e_thinking_2,
avatar.e_thinking_3, avatar.e_thinking_4, avatar.e_thinking_5,
avatar.e_thinking_6, avatar.e_thinking_7, avatar.e_thinking_8],
EmotionState::Laughing => [avatar.e_laughing_0, avatar.e_laughing_1, avatar.e_laughing_2,
avatar.e_laughing_3, avatar.e_laughing_4, avatar.e_laughing_5,
avatar.e_laughing_6, avatar.e_laughing_7, avatar.e_laughing_8],
EmotionState::Crying => [avatar.e_crying_0, avatar.e_crying_1, avatar.e_crying_2,
avatar.e_crying_3, avatar.e_crying_4, avatar.e_crying_5,
avatar.e_crying_6, avatar.e_crying_7, avatar.e_crying_8],
EmotionState::Love => [avatar.e_love_0, avatar.e_love_1, avatar.e_love_2,
avatar.e_love_3, avatar.e_love_4, avatar.e_love_5,
avatar.e_love_6, avatar.e_love_7, avatar.e_love_8],
EmotionState::Confused => [avatar.e_confused_0, avatar.e_confused_1, avatar.e_confused_2,
avatar.e_confused_3, avatar.e_confused_4, avatar.e_confused_5,
avatar.e_confused_6, avatar.e_confused_7, avatar.e_confused_8],
EmotionState::Sleeping => [avatar.e_sleeping_0, avatar.e_sleeping_1, avatar.e_sleeping_2,
avatar.e_sleeping_3, avatar.e_sleeping_4, avatar.e_sleeping_5,
avatar.e_sleeping_6, avatar.e_sleeping_7, avatar.e_sleeping_8],
EmotionState::Wink => [avatar.e_wink_0, avatar.e_wink_1, avatar.e_wink_2,
avatar.e_wink_3, avatar.e_wink_4, avatar.e_wink_5,
avatar.e_wink_6, avatar.e_wink_7, avatar.e_wink_8],
};
collect_uuids(&mut uuids, &emotion_slots);
// Bulk resolve inventory UUIDs to asset paths
let paths: HashMap<Uuid, String> = if uuids.is_empty() {
HashMap::new()
} else {
sqlx::query_as::<_, (Uuid, String)>(
"SELECT id, prop_asset_path FROM auth.inventory WHERE id = ANY($1)",
)
.bind(&uuids)
.fetch_all(executor)
.await?
.into_iter()
.collect()
};
let get_path = |id: Option<Uuid>| -> Option<String> {
id.and_then(|id| paths.get(&id).cloned())
};
Ok(Some(crate::models::AvatarRenderData {
avatar_id,
current_emotion,
skin_layer: [
get_path(avatar.l_skin_0), get_path(avatar.l_skin_1), get_path(avatar.l_skin_2),
get_path(avatar.l_skin_3), get_path(avatar.l_skin_4), get_path(avatar.l_skin_5),
get_path(avatar.l_skin_6), get_path(avatar.l_skin_7), get_path(avatar.l_skin_8),
],
clothes_layer: [
get_path(avatar.l_clothes_0), get_path(avatar.l_clothes_1), get_path(avatar.l_clothes_2),
get_path(avatar.l_clothes_3), get_path(avatar.l_clothes_4), get_path(avatar.l_clothes_5),
get_path(avatar.l_clothes_6), get_path(avatar.l_clothes_7), get_path(avatar.l_clothes_8),
],
accessories_layer: [
get_path(avatar.l_accessories_0), get_path(avatar.l_accessories_1), get_path(avatar.l_accessories_2),
get_path(avatar.l_accessories_3), get_path(avatar.l_accessories_4), get_path(avatar.l_accessories_5),
get_path(avatar.l_accessories_6), get_path(avatar.l_accessories_7), get_path(avatar.l_accessories_8),
],
emotion_layer: [
get_path(emotion_slots[0]), get_path(emotion_slots[1]), get_path(emotion_slots[2]),
get_path(emotion_slots[3]), get_path(emotion_slots[4]), get_path(emotion_slots[5]),
get_path(emotion_slots[6]), get_path(emotion_slots[7]), get_path(emotion_slots[8]),
],
}))
}
/// Get the realm default avatar ID based on gender and age preferences.
async fn get_realm_default_avatar_id<'e>(
executor: impl PgExecutor<'e>,
realm_id: Uuid,
gender: crate::models::GenderPreference,
age: crate::models::AgeCategory,
) -> Result<Option<Uuid>, AppError> {
use crate::models::{AgeCategory, GenderPreference};
// Build column name based on gender and age
let column = match (gender, age) {
(GenderPreference::GenderNeutral, AgeCategory::Child) => "default_avatar_neutral_child",
(GenderPreference::GenderNeutral, AgeCategory::Adult) => "default_avatar_neutral_adult",
(GenderPreference::GenderMale, AgeCategory::Child) => "default_avatar_male_child",
(GenderPreference::GenderMale, AgeCategory::Adult) => "default_avatar_male_adult",
(GenderPreference::GenderFemale, AgeCategory::Child) => "default_avatar_female_child",
(GenderPreference::GenderFemale, AgeCategory::Adult) => "default_avatar_female_adult",
};
let query = format!(
"SELECT {} FROM realm.realms WHERE id = $1",
column
);
let result: Option<(Option<Uuid>,)> = sqlx::query_as(&query)
.bind(realm_id)
.fetch_optional(executor)
.await?;
Ok(result.and_then(|r| r.0))
}
/// Get the server default avatar ID based on gender and age preferences.
async fn get_server_default_avatar_id<'e>(
executor: impl PgExecutor<'e>,
gender: crate::models::GenderPreference,
age: crate::models::AgeCategory,
) -> Result<Option<Uuid>, AppError> {
use crate::models::{AgeCategory, GenderPreference};
// Build column name based on gender and age
let column = match (gender, age) {
(GenderPreference::GenderNeutral, AgeCategory::Child) => "default_avatar_neutral_child",
(GenderPreference::GenderNeutral, AgeCategory::Adult) => "default_avatar_neutral_adult",
(GenderPreference::GenderMale, AgeCategory::Child) => "default_avatar_male_child",
(GenderPreference::GenderMale, AgeCategory::Adult) => "default_avatar_male_adult",
(GenderPreference::GenderFemale, AgeCategory::Child) => "default_avatar_female_child",
(GenderPreference::GenderFemale, AgeCategory::Adult) => "default_avatar_female_adult",
};
let query = format!(
"SELECT {} FROM server.config WHERE id = '00000000-0000-0000-0000-000000000001'",
column
);
let result: Option<(Option<Uuid>,)> = sqlx::query_as(&query)
.fetch_optional(executor)
.await?;
Ok(result.and_then(|r| r.0))
}
/// Select a server avatar for a user in a realm.
/// This updates the selected_server_avatar_id in active_avatars.
/// Uses UPSERT to create the record if it doesn't exist.
pub async fn select_server_avatar<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
server_avatar_id: Uuid,
) -> Result<(), AppError> {
sqlx::query(
r#"
INSERT INTO auth.active_avatars (user_id, realm_id, selected_server_avatar_id, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (user_id, realm_id) DO UPDATE
SET selected_server_avatar_id = EXCLUDED.selected_server_avatar_id,
updated_at = EXCLUDED.updated_at
"#,
)
.bind(user_id)
.bind(realm_id)
.bind(server_avatar_id)
.execute(executor)
.await?;
Ok(())
}
/// Select a realm avatar for a user in a realm.
/// This updates the selected_realm_avatar_id in active_avatars.
/// Uses UPSERT to create the record if it doesn't exist.
pub async fn select_realm_avatar<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
realm_avatar_id: Uuid,
) -> Result<(), AppError> {
sqlx::query(
r#"
INSERT INTO auth.active_avatars (user_id, realm_id, selected_realm_avatar_id, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (user_id, realm_id) DO UPDATE
SET selected_realm_avatar_id = EXCLUDED.selected_realm_avatar_id,
updated_at = EXCLUDED.updated_at
"#,
)
.bind(user_id)
.bind(realm_id)
.bind(realm_avatar_id)
.execute(executor)
.await?;
Ok(())
}
/// Clear avatar selection for a user in a realm.
/// Clears both selected_server_avatar_id and selected_realm_avatar_id.
/// If no record exists, this is a no-op (clearing nothing is success).
pub async fn clear_avatar_selection<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
) -> Result<(), AppError> {
sqlx::query(
r#"
UPDATE auth.active_avatars
SET
selected_server_avatar_id = NULL,
selected_realm_avatar_id = NULL,
updated_at = now()
WHERE user_id = $1 AND realm_id = $2
"#,
)
.bind(user_id)
.bind(realm_id)
.execute(executor)
.await?;
// No error if record doesn't exist - clearing nothing is success
Ok(())
}