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}.
This commit is contained in:
Evan Carroll 2026-01-22 21:04:27 -06:00
parent e4abdb183f
commit 6fb90e42c3
55 changed files with 7392 additions and 512 deletions

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,9 @@ pub mod memberships;
pub mod moderation;
pub mod owner;
pub mod props;
pub mod realm_avatars;
pub mod realms;
pub mod scenes;
pub mod server_avatars;
pub mod spots;
pub mod users;

View file

@ -16,7 +16,11 @@ pub async fn get_active_avatar<'e>(
) -> Result<Option<ActiveAvatar>, AppError> {
let avatar = sqlx::query_as::<_, ActiveAvatar>(
r#"
SELECT user_id, realm_id, avatar_id, current_emotion, updated_at
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
"#,
@ -31,86 +35,54 @@ pub async fn get_active_avatar<'e>(
/// 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>(
executor: impl PgExecutor<'e>,
conn: &mut PgConnection,
pool: &PgPool,
user_id: Uuid,
realm_id: Uuid,
emotion: EmotionState,
) -> Result<[Option<String>; 9], AppError> {
// Map emotion to column prefix
let emotion_prefix = match emotion {
EmotionState::Neutral => "e_neutral",
EmotionState::Happy => "e_happy",
EmotionState::Sad => "e_sad",
EmotionState::Angry => "e_angry",
EmotionState::Surprised => "e_surprised",
EmotionState::Thinking => "e_thinking",
EmotionState::Laughing => "e_laughing",
EmotionState::Crying => "e_crying",
EmotionState::Love => "e_love",
EmotionState::Confused => "e_confused",
EmotionState::Sleeping => "e_sleeping",
EmotionState::Wink => "e_wink",
};
// Get the numeric index for the database
let emotion_index = emotion.to_index() as i16;
// Build dynamic query for the specific emotion's 9 positions
let query = format!(
// First, update the emotion in active_avatars (uses RLS connection)
let update_result = sqlx::query(
r#"
WITH updated AS (
UPDATE auth.active_avatars
SET current_emotion = $3, updated_at = now()
WHERE user_id = $1 AND realm_id = $2
RETURNING avatar_id
)
SELECT
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_0) as p0,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_1) as p1,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_2) as p2,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_3) as p3,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_4) as p4,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_5) as p5,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_6) as p6,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_7) as p7,
(SELECT prop_asset_path FROM auth.inventory WHERE id = a.{prefix}_8) as p8
FROM updated u
JOIN auth.avatars a ON a.id = u.avatar_id
UPDATE auth.active_avatars
SET current_emotion = $3::server.emotion_state, updated_at = now()
WHERE user_id = $1 AND realm_id = $2
"#,
prefix = emotion_prefix
);
)
.bind(user_id)
.bind(realm_id)
.bind(emotion.to_string())
.execute(&mut *conn)
.await?;
let result = sqlx::query_as::<_, EmotionLayerRow>(&query)
.bind(user_id)
.bind(realm_id)
.bind(emotion_index)
.fetch_optional(executor)
.await?;
match result {
Some(row) => Ok([
row.p0, row.p1, row.p2, row.p3, row.p4, row.p5, row.p6, row.p7, row.p8,
]),
None => Err(AppError::NotFound(
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])
}
}
}
/// Row type for emotion layer query.
#[derive(Debug, sqlx::FromRow)]
struct EmotionLayerRow {
p0: Option<String>,
p1: Option<String>,
p2: Option<String>,
p3: Option<String>,
p4: Option<String>,
p5: Option<String>,
p6: Option<String>,
p7: Option<String>,
p8: Option<String>,
}
/// Get emotion availability for a user's avatar in a realm.
///
@ -1469,7 +1441,7 @@ fn collect_uuids(dest: &mut Vec<Uuid>, sources: &[Option<Uuid>]) {
#[derive(Debug, sqlx::FromRow)]
struct AvatarWithEmotion {
pub id: Uuid,
pub current_emotion: i16,
pub current_emotion: EmotionState,
// Content layers
pub l_skin_0: Option<Uuid>,
pub l_skin_1: Option<Uuid>,
@ -1617,22 +1589,18 @@ pub async fn set_emotion_simple<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
emotion: i16,
emotion: EmotionState,
) -> Result<(), AppError> {
if emotion < 0 || emotion > 11 {
return Err(AppError::Validation("Emotion must be 0-11".to_string()));
}
let result = sqlx::query(
r#"
UPDATE auth.active_avatars
SET current_emotion = $3, updated_at = now()
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)
.bind(emotion.to_string())
.execute(executor)
.await?;
@ -1723,3 +1691,472 @@ pub async fn update_avatar_slot(
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(())
}

View file

@ -58,7 +58,8 @@ pub async fn join_channel<'e>(
}
/// Ensure an active avatar exists for a user in a realm.
/// Uses the user's default avatar (slot 0) if none exists.
/// If user has a custom avatar (slot 0), use it. Otherwise, avatar_id is NULL
/// and the system will use server/realm default avatars based on user preferences.
pub async fn ensure_active_avatar<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
@ -67,9 +68,9 @@ pub async fn ensure_active_avatar<'e>(
sqlx::query(
r#"
INSERT INTO auth.active_avatars (user_id, realm_id, avatar_id, current_emotion)
SELECT $1, $2, id, 1
FROM auth.avatars
WHERE user_id = $1 AND slot_number = 0
SELECT $1, $2,
(SELECT id FROM auth.avatars WHERE user_id = $1 AND slot_number = 0),
'happy'::server.emotion_state
ON CONFLICT (user_id, realm_id) DO NOTHING
"#,
)
@ -175,7 +176,7 @@ pub async fn get_channel_members<'e>(
cm.facing_direction,
cm.is_moving,
cm.is_afk,
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
COALESCE(aa.current_emotion, 'happy'::server.emotion_state) as current_emotion,
cm.joined_at,
COALESCE('guest' = ANY(u.tags), false) as is_guest
FROM scene.instance_members cm
@ -214,7 +215,7 @@ pub async fn get_channel_member<'e>(
cm.facing_direction,
cm.is_moving,
cm.is_afk,
COALESCE(aa.current_emotion, 0::smallint) as current_emotion,
COALESCE(aa.current_emotion, 'happy'::server.emotion_state) as current_emotion,
cm.joined_at,
COALESCE('guest' = ANY(u.tags), false) as is_guest
FROM scene.instance_members cm

View file

@ -0,0 +1,880 @@
//! Realm avatar queries.
//!
//! Realm avatars are pre-configured avatar configurations specific to a realm.
//! They reference realm.props directly (not inventory items).
use std::collections::HashMap;
use chrono::{Duration, Utc};
use sqlx::PgExecutor;
use uuid::Uuid;
use crate::models::{AvatarRenderData, EmotionState, RealmAvatar};
use chattyness_error::AppError;
/// Get a realm avatar by slug within a realm.
pub async fn get_realm_avatar_by_slug<'e>(
executor: impl PgExecutor<'e>,
realm_id: Uuid,
slug: &str,
) -> Result<Option<RealmAvatar>, AppError> {
let avatar = sqlx::query_as::<_, RealmAvatar>(
r#"
SELECT *
FROM realm.avatars
WHERE realm_id = $1 AND slug = $2 AND is_active = true
"#,
)
.bind(realm_id)
.bind(slug)
.fetch_optional(executor)
.await?;
Ok(avatar)
}
/// Get a realm avatar by ID.
pub async fn get_realm_avatar_by_id<'e>(
executor: impl PgExecutor<'e>,
avatar_id: Uuid,
) -> Result<Option<RealmAvatar>, AppError> {
let avatar = sqlx::query_as::<_, RealmAvatar>(
r#"
SELECT *
FROM realm.avatars
WHERE id = $1
"#,
)
.bind(avatar_id)
.fetch_optional(executor)
.await?;
Ok(avatar)
}
/// List all active public realm avatars for a realm.
pub async fn list_public_realm_avatars<'e>(
executor: impl PgExecutor<'e>,
realm_id: Uuid,
) -> Result<Vec<RealmAvatar>, AppError> {
let avatars = sqlx::query_as::<_, RealmAvatar>(
r#"
SELECT *
FROM realm.avatars
WHERE realm_id = $1 AND is_active = true AND is_public = true
ORDER BY name ASC
"#,
)
.bind(realm_id)
.fetch_all(executor)
.await?;
Ok(avatars)
}
/// Row type for prop asset lookup.
#[derive(Debug, sqlx::FromRow)]
struct PropAssetRow {
id: Uuid,
asset_path: String,
}
/// Resolve a realm avatar to render data.
/// Joins the avatar's prop UUIDs with realm.props to get asset paths.
pub async fn resolve_realm_avatar_to_render_data<'e>(
executor: impl PgExecutor<'e>,
avatar: &RealmAvatar,
current_emotion: EmotionState,
) -> Result<AvatarRenderData, AppError> {
// Collect all non-null prop UUIDs
let mut prop_ids: Vec<Uuid> = Vec::new();
// Content layers
for id in [
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,
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,
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,
].iter().flatten() {
prop_ids.push(*id);
}
// Get emotion layer slots based on 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],
};
for id in emotion_slots.iter().flatten() {
prop_ids.push(*id);
}
// Bulk lookup all prop asset paths from realm.props
let prop_map: HashMap<Uuid, String> = if prop_ids.is_empty() {
HashMap::new()
} else {
let rows = sqlx::query_as::<_, PropAssetRow>(
r#"
SELECT id, asset_path
FROM realm.props
WHERE id = ANY($1)
"#,
)
.bind(&prop_ids)
.fetch_all(executor)
.await?;
rows.into_iter().map(|r| (r.id, r.asset_path)).collect()
};
// Helper to look up path
let get_path = |id: Option<Uuid>| -> Option<String> {
id.and_then(|id| prop_map.get(&id).cloned())
};
Ok(AvatarRenderData {
avatar_id: 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]),
],
})
}
/// Apply a forced realm avatar to a user.
pub async fn apply_forced_realm_avatar<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
avatar_id: Uuid,
forced_by: Option<Uuid>,
duration: Option<Duration>,
) -> Result<(), AppError> {
let forced_until = duration.map(|d| Utc::now() + d);
sqlx::query(
r#"
UPDATE auth.active_avatars
SET
forced_avatar_id = $3,
forced_avatar_source = 'realm',
forced_by = $4,
forced_until = $5,
updated_at = now()
WHERE user_id = $1 AND realm_id = $2
"#,
)
.bind(user_id)
.bind(realm_id)
.bind(avatar_id)
.bind(forced_by)
.bind(forced_until)
.execute(executor)
.await?;
Ok(())
}
/// Get scene forced avatar configuration.
#[derive(Debug, sqlx::FromRow)]
pub struct SceneForcedAvatar {
pub forced_avatar_id: Uuid,
pub forced_avatar_source: String,
}
pub async fn get_scene_forced_avatar<'e>(
executor: impl PgExecutor<'e>,
scene_id: Uuid,
) -> Result<Option<SceneForcedAvatar>, AppError> {
let info = sqlx::query_as::<_, SceneForcedAvatar>(
r#"
SELECT forced_avatar_id, forced_avatar_source
FROM realm.scenes
WHERE id = $1
AND forced_avatar_id IS NOT NULL
"#,
)
.bind(scene_id)
.fetch_optional(executor)
.await?;
Ok(info)
}
/// Apply scene-forced avatar to a user's active avatar.
pub async fn apply_scene_forced_avatar<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
avatar_id: Uuid,
) -> Result<(), AppError> {
sqlx::query(
r#"
UPDATE auth.active_avatars
SET
forced_avatar_id = $3,
forced_avatar_source = 'scene',
forced_by = NULL,
forced_until = NULL,
updated_at = now()
WHERE user_id = $1 AND realm_id = $2
"#,
)
.bind(user_id)
.bind(realm_id)
.bind(avatar_id)
.execute(executor)
.await?;
Ok(())
}
// =============================================================================
// CRUD Operations for Admin API
// =============================================================================
use crate::models::{CreateRealmAvatarRequest, RealmAvatarSummary, UpdateRealmAvatarRequest};
/// List all realm avatars for a realm (for admin).
pub async fn list_all_realm_avatars<'e>(
executor: impl PgExecutor<'e>,
realm_id: Uuid,
) -> Result<Vec<RealmAvatarSummary>, AppError> {
let avatars = sqlx::query_as::<_, RealmAvatarSummary>(
r#"
SELECT id, realm_id, slug, name, description, is_public, is_active, thumbnail_path, created_at
FROM realm.avatars
WHERE realm_id = $1
ORDER BY name ASC
"#,
)
.bind(realm_id)
.fetch_all(executor)
.await?;
Ok(avatars)
}
/// Check if a realm avatar slug is available within a realm.
pub async fn is_avatar_slug_available<'e>(
executor: impl PgExecutor<'e>,
realm_id: Uuid,
slug: &str,
) -> Result<bool, AppError> {
let result: (bool,) = sqlx::query_as(
r#"
SELECT NOT EXISTS(
SELECT 1 FROM realm.avatars WHERE realm_id = $1 AND slug = $2
)
"#,
)
.bind(realm_id)
.bind(slug)
.fetch_one(executor)
.await?;
Ok(result.0)
}
/// Create a new realm avatar.
pub async fn create_realm_avatar<'e>(
executor: impl PgExecutor<'e>,
realm_id: Uuid,
req: &CreateRealmAvatarRequest,
created_by: Option<Uuid>,
) -> Result<RealmAvatar, AppError> {
let slug = req.slug_or_generate();
let avatar = sqlx::query_as::<_, RealmAvatar>(
r#"
INSERT INTO realm.avatars (
realm_id, slug, name, description, is_public, is_active, thumbnail_path, created_by,
l_skin_0, l_skin_1, l_skin_2, l_skin_3, l_skin_4, l_skin_5, l_skin_6, l_skin_7, l_skin_8,
l_clothes_0, l_clothes_1, l_clothes_2, l_clothes_3, l_clothes_4, l_clothes_5, l_clothes_6, l_clothes_7, l_clothes_8,
l_accessories_0, l_accessories_1, l_accessories_2, l_accessories_3, l_accessories_4, l_accessories_5, l_accessories_6, l_accessories_7, l_accessories_8,
e_neutral_0, e_neutral_1, e_neutral_2, e_neutral_3, e_neutral_4, e_neutral_5, e_neutral_6, e_neutral_7, e_neutral_8,
e_happy_0, e_happy_1, e_happy_2, e_happy_3, e_happy_4, e_happy_5, e_happy_6, e_happy_7, e_happy_8,
e_sad_0, e_sad_1, e_sad_2, e_sad_3, e_sad_4, e_sad_5, e_sad_6, e_sad_7, e_sad_8,
e_angry_0, e_angry_1, e_angry_2, e_angry_3, e_angry_4, e_angry_5, e_angry_6, e_angry_7, e_angry_8,
e_surprised_0, e_surprised_1, e_surprised_2, e_surprised_3, e_surprised_4, e_surprised_5, e_surprised_6, e_surprised_7, e_surprised_8,
e_thinking_0, e_thinking_1, e_thinking_2, e_thinking_3, e_thinking_4, e_thinking_5, e_thinking_6, e_thinking_7, e_thinking_8,
e_laughing_0, e_laughing_1, e_laughing_2, e_laughing_3, e_laughing_4, e_laughing_5, e_laughing_6, e_laughing_7, e_laughing_8,
e_crying_0, e_crying_1, e_crying_2, e_crying_3, e_crying_4, e_crying_5, e_crying_6, e_crying_7, e_crying_8,
e_love_0, e_love_1, e_love_2, e_love_3, e_love_4, e_love_5, e_love_6, e_love_7, e_love_8,
e_confused_0, e_confused_1, e_confused_2, e_confused_3, e_confused_4, e_confused_5, e_confused_6, e_confused_7, e_confused_8,
e_sleeping_0, e_sleeping_1, e_sleeping_2, e_sleeping_3, e_sleeping_4, e_sleeping_5, e_sleeping_6, e_sleeping_7, e_sleeping_8,
e_wink_0, e_wink_1, e_wink_2, e_wink_3, e_wink_4, e_wink_5, e_wink_6, e_wink_7, e_wink_8
)
VALUES (
$1, $2, $3, $4, $5, true, $6, $7,
$8, $9, $10, $11, $12, $13, $14, $15, $16,
$17, $18, $19, $20, $21, $22, $23, $24, $25,
$26, $27, $28, $29, $30, $31, $32, $33, $34,
$35, $36, $37, $38, $39, $40, $41, $42, $43,
$44, $45, $46, $47, $48, $49, $50, $51, $52,
$53, $54, $55, $56, $57, $58, $59, $60, $61,
$62, $63, $64, $65, $66, $67, $68, $69, $70,
$71, $72, $73, $74, $75, $76, $77, $78, $79,
$80, $81, $82, $83, $84, $85, $86, $87, $88,
$89, $90, $91, $92, $93, $94, $95, $96, $97,
$98, $99, $100, $101, $102, $103, $104, $105, $106,
$107, $108, $109, $110, $111, $112, $113, $114, $115,
$116, $117, $118, $119, $120, $121, $122, $123, $124,
$125, $126, $127, $128, $129, $130, $131, $132, $133,
$134, $135, $136, $137, $138, $139, $140, $141, $142
)
RETURNING *
"#,
)
.bind(realm_id)
.bind(&slug)
.bind(&req.name)
.bind(&req.description)
.bind(req.is_public)
.bind(&req.thumbnail_path)
.bind(created_by)
// Skin layer
.bind(req.l_skin_0)
.bind(req.l_skin_1)
.bind(req.l_skin_2)
.bind(req.l_skin_3)
.bind(req.l_skin_4)
.bind(req.l_skin_5)
.bind(req.l_skin_6)
.bind(req.l_skin_7)
.bind(req.l_skin_8)
// Clothes layer
.bind(req.l_clothes_0)
.bind(req.l_clothes_1)
.bind(req.l_clothes_2)
.bind(req.l_clothes_3)
.bind(req.l_clothes_4)
.bind(req.l_clothes_5)
.bind(req.l_clothes_6)
.bind(req.l_clothes_7)
.bind(req.l_clothes_8)
// Accessories layer
.bind(req.l_accessories_0)
.bind(req.l_accessories_1)
.bind(req.l_accessories_2)
.bind(req.l_accessories_3)
.bind(req.l_accessories_4)
.bind(req.l_accessories_5)
.bind(req.l_accessories_6)
.bind(req.l_accessories_7)
.bind(req.l_accessories_8)
// Neutral emotion
.bind(req.e_neutral_0)
.bind(req.e_neutral_1)
.bind(req.e_neutral_2)
.bind(req.e_neutral_3)
.bind(req.e_neutral_4)
.bind(req.e_neutral_5)
.bind(req.e_neutral_6)
.bind(req.e_neutral_7)
.bind(req.e_neutral_8)
// Happy emotion
.bind(req.e_happy_0)
.bind(req.e_happy_1)
.bind(req.e_happy_2)
.bind(req.e_happy_3)
.bind(req.e_happy_4)
.bind(req.e_happy_5)
.bind(req.e_happy_6)
.bind(req.e_happy_7)
.bind(req.e_happy_8)
// Sad emotion
.bind(req.e_sad_0)
.bind(req.e_sad_1)
.bind(req.e_sad_2)
.bind(req.e_sad_3)
.bind(req.e_sad_4)
.bind(req.e_sad_5)
.bind(req.e_sad_6)
.bind(req.e_sad_7)
.bind(req.e_sad_8)
// Angry emotion
.bind(req.e_angry_0)
.bind(req.e_angry_1)
.bind(req.e_angry_2)
.bind(req.e_angry_3)
.bind(req.e_angry_4)
.bind(req.e_angry_5)
.bind(req.e_angry_6)
.bind(req.e_angry_7)
.bind(req.e_angry_8)
// Surprised emotion
.bind(req.e_surprised_0)
.bind(req.e_surprised_1)
.bind(req.e_surprised_2)
.bind(req.e_surprised_3)
.bind(req.e_surprised_4)
.bind(req.e_surprised_5)
.bind(req.e_surprised_6)
.bind(req.e_surprised_7)
.bind(req.e_surprised_8)
// Thinking emotion
.bind(req.e_thinking_0)
.bind(req.e_thinking_1)
.bind(req.e_thinking_2)
.bind(req.e_thinking_3)
.bind(req.e_thinking_4)
.bind(req.e_thinking_5)
.bind(req.e_thinking_6)
.bind(req.e_thinking_7)
.bind(req.e_thinking_8)
// Laughing emotion
.bind(req.e_laughing_0)
.bind(req.e_laughing_1)
.bind(req.e_laughing_2)
.bind(req.e_laughing_3)
.bind(req.e_laughing_4)
.bind(req.e_laughing_5)
.bind(req.e_laughing_6)
.bind(req.e_laughing_7)
.bind(req.e_laughing_8)
// Crying emotion
.bind(req.e_crying_0)
.bind(req.e_crying_1)
.bind(req.e_crying_2)
.bind(req.e_crying_3)
.bind(req.e_crying_4)
.bind(req.e_crying_5)
.bind(req.e_crying_6)
.bind(req.e_crying_7)
.bind(req.e_crying_8)
// Love emotion
.bind(req.e_love_0)
.bind(req.e_love_1)
.bind(req.e_love_2)
.bind(req.e_love_3)
.bind(req.e_love_4)
.bind(req.e_love_5)
.bind(req.e_love_6)
.bind(req.e_love_7)
.bind(req.e_love_8)
// Confused emotion
.bind(req.e_confused_0)
.bind(req.e_confused_1)
.bind(req.e_confused_2)
.bind(req.e_confused_3)
.bind(req.e_confused_4)
.bind(req.e_confused_5)
.bind(req.e_confused_6)
.bind(req.e_confused_7)
.bind(req.e_confused_8)
// Sleeping emotion
.bind(req.e_sleeping_0)
.bind(req.e_sleeping_1)
.bind(req.e_sleeping_2)
.bind(req.e_sleeping_3)
.bind(req.e_sleeping_4)
.bind(req.e_sleeping_5)
.bind(req.e_sleeping_6)
.bind(req.e_sleeping_7)
.bind(req.e_sleeping_8)
// Wink emotion
.bind(req.e_wink_0)
.bind(req.e_wink_1)
.bind(req.e_wink_2)
.bind(req.e_wink_3)
.bind(req.e_wink_4)
.bind(req.e_wink_5)
.bind(req.e_wink_6)
.bind(req.e_wink_7)
.bind(req.e_wink_8)
.fetch_one(executor)
.await?;
Ok(avatar)
}
/// Update a realm avatar.
pub async fn update_realm_avatar<'e>(
executor: impl PgExecutor<'e>,
avatar_id: Uuid,
req: &UpdateRealmAvatarRequest,
) -> Result<RealmAvatar, AppError> {
let avatar = sqlx::query_as::<_, RealmAvatar>(
r#"
UPDATE realm.avatars SET
name = COALESCE($2, name),
description = COALESCE($3, description),
is_public = COALESCE($4, is_public),
is_active = COALESCE($5, is_active),
thumbnail_path = COALESCE($6, thumbnail_path),
l_skin_0 = COALESCE($7, l_skin_0),
l_skin_1 = COALESCE($8, l_skin_1),
l_skin_2 = COALESCE($9, l_skin_2),
l_skin_3 = COALESCE($10, l_skin_3),
l_skin_4 = COALESCE($11, l_skin_4),
l_skin_5 = COALESCE($12, l_skin_5),
l_skin_6 = COALESCE($13, l_skin_6),
l_skin_7 = COALESCE($14, l_skin_7),
l_skin_8 = COALESCE($15, l_skin_8),
l_clothes_0 = COALESCE($16, l_clothes_0),
l_clothes_1 = COALESCE($17, l_clothes_1),
l_clothes_2 = COALESCE($18, l_clothes_2),
l_clothes_3 = COALESCE($19, l_clothes_3),
l_clothes_4 = COALESCE($20, l_clothes_4),
l_clothes_5 = COALESCE($21, l_clothes_5),
l_clothes_6 = COALESCE($22, l_clothes_6),
l_clothes_7 = COALESCE($23, l_clothes_7),
l_clothes_8 = COALESCE($24, l_clothes_8),
l_accessories_0 = COALESCE($25, l_accessories_0),
l_accessories_1 = COALESCE($26, l_accessories_1),
l_accessories_2 = COALESCE($27, l_accessories_2),
l_accessories_3 = COALESCE($28, l_accessories_3),
l_accessories_4 = COALESCE($29, l_accessories_4),
l_accessories_5 = COALESCE($30, l_accessories_5),
l_accessories_6 = COALESCE($31, l_accessories_6),
l_accessories_7 = COALESCE($32, l_accessories_7),
l_accessories_8 = COALESCE($33, l_accessories_8),
e_neutral_0 = COALESCE($34, e_neutral_0),
e_neutral_1 = COALESCE($35, e_neutral_1),
e_neutral_2 = COALESCE($36, e_neutral_2),
e_neutral_3 = COALESCE($37, e_neutral_3),
e_neutral_4 = COALESCE($38, e_neutral_4),
e_neutral_5 = COALESCE($39, e_neutral_5),
e_neutral_6 = COALESCE($40, e_neutral_6),
e_neutral_7 = COALESCE($41, e_neutral_7),
e_neutral_8 = COALESCE($42, e_neutral_8),
e_happy_0 = COALESCE($43, e_happy_0),
e_happy_1 = COALESCE($44, e_happy_1),
e_happy_2 = COALESCE($45, e_happy_2),
e_happy_3 = COALESCE($46, e_happy_3),
e_happy_4 = COALESCE($47, e_happy_4),
e_happy_5 = COALESCE($48, e_happy_5),
e_happy_6 = COALESCE($49, e_happy_6),
e_happy_7 = COALESCE($50, e_happy_7),
e_happy_8 = COALESCE($51, e_happy_8),
e_sad_0 = COALESCE($52, e_sad_0),
e_sad_1 = COALESCE($53, e_sad_1),
e_sad_2 = COALESCE($54, e_sad_2),
e_sad_3 = COALESCE($55, e_sad_3),
e_sad_4 = COALESCE($56, e_sad_4),
e_sad_5 = COALESCE($57, e_sad_5),
e_sad_6 = COALESCE($58, e_sad_6),
e_sad_7 = COALESCE($59, e_sad_7),
e_sad_8 = COALESCE($60, e_sad_8),
e_angry_0 = COALESCE($61, e_angry_0),
e_angry_1 = COALESCE($62, e_angry_1),
e_angry_2 = COALESCE($63, e_angry_2),
e_angry_3 = COALESCE($64, e_angry_3),
e_angry_4 = COALESCE($65, e_angry_4),
e_angry_5 = COALESCE($66, e_angry_5),
e_angry_6 = COALESCE($67, e_angry_6),
e_angry_7 = COALESCE($68, e_angry_7),
e_angry_8 = COALESCE($69, e_angry_8),
e_surprised_0 = COALESCE($70, e_surprised_0),
e_surprised_1 = COALESCE($71, e_surprised_1),
e_surprised_2 = COALESCE($72, e_surprised_2),
e_surprised_3 = COALESCE($73, e_surprised_3),
e_surprised_4 = COALESCE($74, e_surprised_4),
e_surprised_5 = COALESCE($75, e_surprised_5),
e_surprised_6 = COALESCE($76, e_surprised_6),
e_surprised_7 = COALESCE($77, e_surprised_7),
e_surprised_8 = COALESCE($78, e_surprised_8),
e_thinking_0 = COALESCE($79, e_thinking_0),
e_thinking_1 = COALESCE($80, e_thinking_1),
e_thinking_2 = COALESCE($81, e_thinking_2),
e_thinking_3 = COALESCE($82, e_thinking_3),
e_thinking_4 = COALESCE($83, e_thinking_4),
e_thinking_5 = COALESCE($84, e_thinking_5),
e_thinking_6 = COALESCE($85, e_thinking_6),
e_thinking_7 = COALESCE($86, e_thinking_7),
e_thinking_8 = COALESCE($87, e_thinking_8),
e_laughing_0 = COALESCE($88, e_laughing_0),
e_laughing_1 = COALESCE($89, e_laughing_1),
e_laughing_2 = COALESCE($90, e_laughing_2),
e_laughing_3 = COALESCE($91, e_laughing_3),
e_laughing_4 = COALESCE($92, e_laughing_4),
e_laughing_5 = COALESCE($93, e_laughing_5),
e_laughing_6 = COALESCE($94, e_laughing_6),
e_laughing_7 = COALESCE($95, e_laughing_7),
e_laughing_8 = COALESCE($96, e_laughing_8),
e_crying_0 = COALESCE($97, e_crying_0),
e_crying_1 = COALESCE($98, e_crying_1),
e_crying_2 = COALESCE($99, e_crying_2),
e_crying_3 = COALESCE($100, e_crying_3),
e_crying_4 = COALESCE($101, e_crying_4),
e_crying_5 = COALESCE($102, e_crying_5),
e_crying_6 = COALESCE($103, e_crying_6),
e_crying_7 = COALESCE($104, e_crying_7),
e_crying_8 = COALESCE($105, e_crying_8),
e_love_0 = COALESCE($106, e_love_0),
e_love_1 = COALESCE($107, e_love_1),
e_love_2 = COALESCE($108, e_love_2),
e_love_3 = COALESCE($109, e_love_3),
e_love_4 = COALESCE($110, e_love_4),
e_love_5 = COALESCE($111, e_love_5),
e_love_6 = COALESCE($112, e_love_6),
e_love_7 = COALESCE($113, e_love_7),
e_love_8 = COALESCE($114, e_love_8),
e_confused_0 = COALESCE($115, e_confused_0),
e_confused_1 = COALESCE($116, e_confused_1),
e_confused_2 = COALESCE($117, e_confused_2),
e_confused_3 = COALESCE($118, e_confused_3),
e_confused_4 = COALESCE($119, e_confused_4),
e_confused_5 = COALESCE($120, e_confused_5),
e_confused_6 = COALESCE($121, e_confused_6),
e_confused_7 = COALESCE($122, e_confused_7),
e_confused_8 = COALESCE($123, e_confused_8),
e_sleeping_0 = COALESCE($124, e_sleeping_0),
e_sleeping_1 = COALESCE($125, e_sleeping_1),
e_sleeping_2 = COALESCE($126, e_sleeping_2),
e_sleeping_3 = COALESCE($127, e_sleeping_3),
e_sleeping_4 = COALESCE($128, e_sleeping_4),
e_sleeping_5 = COALESCE($129, e_sleeping_5),
e_sleeping_6 = COALESCE($130, e_sleeping_6),
e_sleeping_7 = COALESCE($131, e_sleeping_7),
e_sleeping_8 = COALESCE($132, e_sleeping_8),
e_wink_0 = COALESCE($133, e_wink_0),
e_wink_1 = COALESCE($134, e_wink_1),
e_wink_2 = COALESCE($135, e_wink_2),
e_wink_3 = COALESCE($136, e_wink_3),
e_wink_4 = COALESCE($137, e_wink_4),
e_wink_5 = COALESCE($138, e_wink_5),
e_wink_6 = COALESCE($139, e_wink_6),
e_wink_7 = COALESCE($140, e_wink_7),
e_wink_8 = COALESCE($141, e_wink_8),
updated_at = now()
WHERE id = $1
RETURNING *
"#,
)
.bind(avatar_id)
.bind(&req.name)
.bind(&req.description)
.bind(req.is_public)
.bind(req.is_active)
.bind(&req.thumbnail_path)
// Skin layer
.bind(req.l_skin_0)
.bind(req.l_skin_1)
.bind(req.l_skin_2)
.bind(req.l_skin_3)
.bind(req.l_skin_4)
.bind(req.l_skin_5)
.bind(req.l_skin_6)
.bind(req.l_skin_7)
.bind(req.l_skin_8)
// Clothes layer
.bind(req.l_clothes_0)
.bind(req.l_clothes_1)
.bind(req.l_clothes_2)
.bind(req.l_clothes_3)
.bind(req.l_clothes_4)
.bind(req.l_clothes_5)
.bind(req.l_clothes_6)
.bind(req.l_clothes_7)
.bind(req.l_clothes_8)
// Accessories layer
.bind(req.l_accessories_0)
.bind(req.l_accessories_1)
.bind(req.l_accessories_2)
.bind(req.l_accessories_3)
.bind(req.l_accessories_4)
.bind(req.l_accessories_5)
.bind(req.l_accessories_6)
.bind(req.l_accessories_7)
.bind(req.l_accessories_8)
// Neutral emotion
.bind(req.e_neutral_0)
.bind(req.e_neutral_1)
.bind(req.e_neutral_2)
.bind(req.e_neutral_3)
.bind(req.e_neutral_4)
.bind(req.e_neutral_5)
.bind(req.e_neutral_6)
.bind(req.e_neutral_7)
.bind(req.e_neutral_8)
// Happy emotion
.bind(req.e_happy_0)
.bind(req.e_happy_1)
.bind(req.e_happy_2)
.bind(req.e_happy_3)
.bind(req.e_happy_4)
.bind(req.e_happy_5)
.bind(req.e_happy_6)
.bind(req.e_happy_7)
.bind(req.e_happy_8)
// Sad emotion
.bind(req.e_sad_0)
.bind(req.e_sad_1)
.bind(req.e_sad_2)
.bind(req.e_sad_3)
.bind(req.e_sad_4)
.bind(req.e_sad_5)
.bind(req.e_sad_6)
.bind(req.e_sad_7)
.bind(req.e_sad_8)
// Angry emotion
.bind(req.e_angry_0)
.bind(req.e_angry_1)
.bind(req.e_angry_2)
.bind(req.e_angry_3)
.bind(req.e_angry_4)
.bind(req.e_angry_5)
.bind(req.e_angry_6)
.bind(req.e_angry_7)
.bind(req.e_angry_8)
// Surprised emotion
.bind(req.e_surprised_0)
.bind(req.e_surprised_1)
.bind(req.e_surprised_2)
.bind(req.e_surprised_3)
.bind(req.e_surprised_4)
.bind(req.e_surprised_5)
.bind(req.e_surprised_6)
.bind(req.e_surprised_7)
.bind(req.e_surprised_8)
// Thinking emotion
.bind(req.e_thinking_0)
.bind(req.e_thinking_1)
.bind(req.e_thinking_2)
.bind(req.e_thinking_3)
.bind(req.e_thinking_4)
.bind(req.e_thinking_5)
.bind(req.e_thinking_6)
.bind(req.e_thinking_7)
.bind(req.e_thinking_8)
// Laughing emotion
.bind(req.e_laughing_0)
.bind(req.e_laughing_1)
.bind(req.e_laughing_2)
.bind(req.e_laughing_3)
.bind(req.e_laughing_4)
.bind(req.e_laughing_5)
.bind(req.e_laughing_6)
.bind(req.e_laughing_7)
.bind(req.e_laughing_8)
// Crying emotion
.bind(req.e_crying_0)
.bind(req.e_crying_1)
.bind(req.e_crying_2)
.bind(req.e_crying_3)
.bind(req.e_crying_4)
.bind(req.e_crying_5)
.bind(req.e_crying_6)
.bind(req.e_crying_7)
.bind(req.e_crying_8)
// Love emotion
.bind(req.e_love_0)
.bind(req.e_love_1)
.bind(req.e_love_2)
.bind(req.e_love_3)
.bind(req.e_love_4)
.bind(req.e_love_5)
.bind(req.e_love_6)
.bind(req.e_love_7)
.bind(req.e_love_8)
// Confused emotion
.bind(req.e_confused_0)
.bind(req.e_confused_1)
.bind(req.e_confused_2)
.bind(req.e_confused_3)
.bind(req.e_confused_4)
.bind(req.e_confused_5)
.bind(req.e_confused_6)
.bind(req.e_confused_7)
.bind(req.e_confused_8)
// Sleeping emotion
.bind(req.e_sleeping_0)
.bind(req.e_sleeping_1)
.bind(req.e_sleeping_2)
.bind(req.e_sleeping_3)
.bind(req.e_sleeping_4)
.bind(req.e_sleeping_5)
.bind(req.e_sleeping_6)
.bind(req.e_sleeping_7)
.bind(req.e_sleeping_8)
// Wink emotion
.bind(req.e_wink_0)
.bind(req.e_wink_1)
.bind(req.e_wink_2)
.bind(req.e_wink_3)
.bind(req.e_wink_4)
.bind(req.e_wink_5)
.bind(req.e_wink_6)
.bind(req.e_wink_7)
.bind(req.e_wink_8)
.fetch_one(executor)
.await?;
Ok(avatar)
}
/// Delete a realm avatar.
pub async fn delete_realm_avatar<'e>(
executor: impl PgExecutor<'e>,
avatar_id: Uuid,
) -> Result<(), AppError> {
sqlx::query(
r#"
DELETE FROM realm.avatars
WHERE id = $1
"#,
)
.bind(avatar_id)
.execute(executor)
.await?;
Ok(())
}

View file

@ -0,0 +1,902 @@
//! Server avatar queries.
//!
//! Server avatars are pre-configured avatar configurations available globally
//! across all realms. They reference server.props directly (not inventory items).
use std::collections::HashMap;
use chrono::{DateTime, Duration, Utc};
use sqlx::PgExecutor;
use uuid::Uuid;
use crate::models::{AvatarRenderData, EmotionState, ServerAvatar};
use chattyness_error::AppError;
/// Get a server avatar by slug.
pub async fn get_server_avatar_by_slug<'e>(
executor: impl PgExecutor<'e>,
slug: &str,
) -> Result<Option<ServerAvatar>, AppError> {
let avatar = sqlx::query_as::<_, ServerAvatar>(
r#"
SELECT *
FROM server.avatars
WHERE slug = $1 AND is_active = true
"#,
)
.bind(slug)
.fetch_optional(executor)
.await?;
Ok(avatar)
}
/// Get a server avatar by ID.
pub async fn get_server_avatar_by_id<'e>(
executor: impl PgExecutor<'e>,
avatar_id: Uuid,
) -> Result<Option<ServerAvatar>, AppError> {
let avatar = sqlx::query_as::<_, ServerAvatar>(
r#"
SELECT *
FROM server.avatars
WHERE id = $1
"#,
)
.bind(avatar_id)
.fetch_optional(executor)
.await?;
Ok(avatar)
}
/// List all active public server avatars.
pub async fn list_public_server_avatars<'e>(
executor: impl PgExecutor<'e>,
) -> Result<Vec<ServerAvatar>, AppError> {
let avatars = sqlx::query_as::<_, ServerAvatar>(
r#"
SELECT *
FROM server.avatars
WHERE is_active = true AND is_public = true
ORDER BY name ASC
"#,
)
.fetch_all(executor)
.await?;
Ok(avatars)
}
/// Row type for prop asset lookup.
#[derive(Debug, sqlx::FromRow)]
struct PropAssetRow {
id: Uuid,
asset_path: String,
}
/// Resolve a server avatar to render data.
/// Joins the avatar's prop UUIDs with server.props to get asset paths.
pub async fn resolve_server_avatar_to_render_data<'e>(
executor: impl PgExecutor<'e>,
avatar: &ServerAvatar,
current_emotion: EmotionState,
) -> Result<AvatarRenderData, AppError> {
// Collect all non-null prop UUIDs
let mut prop_ids: Vec<Uuid> = Vec::new();
// Content layers
for id in [
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,
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,
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,
].iter().flatten() {
prop_ids.push(*id);
}
// Get emotion layer slots based on 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],
};
for id in emotion_slots.iter().flatten() {
prop_ids.push(*id);
}
// Bulk lookup all prop asset paths
let prop_map: HashMap<Uuid, String> = if prop_ids.is_empty() {
HashMap::new()
} else {
let rows = sqlx::query_as::<_, PropAssetRow>(
r#"
SELECT id, asset_path
FROM server.props
WHERE id = ANY($1)
"#,
)
.bind(&prop_ids)
.fetch_all(executor)
.await?;
rows.into_iter().map(|r| (r.id, r.asset_path)).collect()
};
// Helper to look up path
let get_path = |id: Option<Uuid>| -> Option<String> {
id.and_then(|id| prop_map.get(&id).cloned())
};
Ok(AvatarRenderData {
avatar_id: 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]),
],
})
}
/// Apply a forced server avatar to a user.
pub async fn apply_forced_server_avatar<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
avatar_id: Uuid,
forced_by: Option<Uuid>,
duration: Option<Duration>,
) -> Result<(), AppError> {
let forced_until = duration.map(|d| Utc::now() + d);
sqlx::query(
r#"
UPDATE auth.active_avatars
SET
forced_avatar_id = $3,
forced_avatar_source = 'server',
forced_by = $4,
forced_until = $5,
updated_at = now()
WHERE user_id = $1 AND realm_id = $2
"#,
)
.bind(user_id)
.bind(realm_id)
.bind(avatar_id)
.bind(forced_by)
.bind(forced_until)
.execute(executor)
.await?;
Ok(())
}
/// Clear the forced avatar for a user.
pub async fn clear_forced_avatar<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
) -> Result<(), AppError> {
sqlx::query(
r#"
UPDATE auth.active_avatars
SET
forced_avatar_id = NULL,
forced_avatar_source = NULL,
forced_by = NULL,
forced_until = NULL,
updated_at = now()
WHERE user_id = $1 AND realm_id = $2
"#,
)
.bind(user_id)
.bind(realm_id)
.execute(executor)
.await?;
Ok(())
}
/// Check if a user has an active forced avatar (not expired).
pub async fn has_active_forced_avatar<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
) -> Result<bool, AppError> {
let result: (bool,) = sqlx::query_as(
r#"
SELECT EXISTS(
SELECT 1 FROM auth.active_avatars
WHERE user_id = $1
AND realm_id = $2
AND forced_avatar_id IS NOT NULL
AND (forced_until IS NULL OR forced_until > now())
)
"#,
)
.bind(user_id)
.bind(realm_id)
.fetch_one(executor)
.await?;
Ok(result.0)
}
/// Get the forced avatar info for a user if active.
#[derive(Debug, sqlx::FromRow)]
pub struct ForcedAvatarInfo {
pub forced_avatar_id: Uuid,
pub forced_avatar_source: String,
pub forced_by: Option<Uuid>,
pub forced_until: Option<DateTime<Utc>>,
}
pub async fn get_forced_avatar_info<'e>(
executor: impl PgExecutor<'e>,
user_id: Uuid,
realm_id: Uuid,
) -> Result<Option<ForcedAvatarInfo>, AppError> {
let info = sqlx::query_as::<_, ForcedAvatarInfo>(
r#"
SELECT
forced_avatar_id,
forced_avatar_source,
forced_by,
forced_until
FROM auth.active_avatars
WHERE user_id = $1
AND realm_id = $2
AND forced_avatar_id IS NOT NULL
AND (forced_until IS NULL OR forced_until > now())
"#,
)
.bind(user_id)
.bind(realm_id)
.fetch_optional(executor)
.await?;
Ok(info)
}
// =============================================================================
// CRUD Operations for Admin API
// =============================================================================
use crate::models::{CreateServerAvatarRequest, ServerAvatarSummary, UpdateServerAvatarRequest};
/// List all server avatars (for admin).
pub async fn list_all_server_avatars<'e>(
executor: impl PgExecutor<'e>,
) -> Result<Vec<ServerAvatarSummary>, AppError> {
let avatars = sqlx::query_as::<_, ServerAvatarSummary>(
r#"
SELECT id, slug, name, description, is_public, is_active, thumbnail_path, created_at
FROM server.avatars
ORDER BY name ASC
"#,
)
.fetch_all(executor)
.await?;
Ok(avatars)
}
/// Check if a server avatar slug is available.
pub async fn is_avatar_slug_available<'e>(
executor: impl PgExecutor<'e>,
slug: &str,
) -> Result<bool, AppError> {
let result: (bool,) = sqlx::query_as(
r#"
SELECT NOT EXISTS(
SELECT 1 FROM server.avatars WHERE slug = $1
)
"#,
)
.bind(slug)
.fetch_one(executor)
.await?;
Ok(result.0)
}
/// Create a new server avatar.
pub async fn create_server_avatar<'e>(
executor: impl PgExecutor<'e>,
req: &CreateServerAvatarRequest,
created_by: Option<Uuid>,
) -> Result<ServerAvatar, AppError> {
let slug = req.slug_or_generate();
let avatar = sqlx::query_as::<_, ServerAvatar>(
r#"
INSERT INTO server.avatars (
slug, name, description, is_public, is_active, thumbnail_path, created_by,
l_skin_0, l_skin_1, l_skin_2, l_skin_3, l_skin_4, l_skin_5, l_skin_6, l_skin_7, l_skin_8,
l_clothes_0, l_clothes_1, l_clothes_2, l_clothes_3, l_clothes_4, l_clothes_5, l_clothes_6, l_clothes_7, l_clothes_8,
l_accessories_0, l_accessories_1, l_accessories_2, l_accessories_3, l_accessories_4, l_accessories_5, l_accessories_6, l_accessories_7, l_accessories_8,
e_neutral_0, e_neutral_1, e_neutral_2, e_neutral_3, e_neutral_4, e_neutral_5, e_neutral_6, e_neutral_7, e_neutral_8,
e_happy_0, e_happy_1, e_happy_2, e_happy_3, e_happy_4, e_happy_5, e_happy_6, e_happy_7, e_happy_8,
e_sad_0, e_sad_1, e_sad_2, e_sad_3, e_sad_4, e_sad_5, e_sad_6, e_sad_7, e_sad_8,
e_angry_0, e_angry_1, e_angry_2, e_angry_3, e_angry_4, e_angry_5, e_angry_6, e_angry_7, e_angry_8,
e_surprised_0, e_surprised_1, e_surprised_2, e_surprised_3, e_surprised_4, e_surprised_5, e_surprised_6, e_surprised_7, e_surprised_8,
e_thinking_0, e_thinking_1, e_thinking_2, e_thinking_3, e_thinking_4, e_thinking_5, e_thinking_6, e_thinking_7, e_thinking_8,
e_laughing_0, e_laughing_1, e_laughing_2, e_laughing_3, e_laughing_4, e_laughing_5, e_laughing_6, e_laughing_7, e_laughing_8,
e_crying_0, e_crying_1, e_crying_2, e_crying_3, e_crying_4, e_crying_5, e_crying_6, e_crying_7, e_crying_8,
e_love_0, e_love_1, e_love_2, e_love_3, e_love_4, e_love_5, e_love_6, e_love_7, e_love_8,
e_confused_0, e_confused_1, e_confused_2, e_confused_3, e_confused_4, e_confused_5, e_confused_6, e_confused_7, e_confused_8,
e_sleeping_0, e_sleeping_1, e_sleeping_2, e_sleeping_3, e_sleeping_4, e_sleeping_5, e_sleeping_6, e_sleeping_7, e_sleeping_8,
e_wink_0, e_wink_1, e_wink_2, e_wink_3, e_wink_4, e_wink_5, e_wink_6, e_wink_7, e_wink_8
)
VALUES (
$1, $2, $3, $4, true, $5, $6,
$7, $8, $9, $10, $11, $12, $13, $14, $15,
$16, $17, $18, $19, $20, $21, $22, $23, $24,
$25, $26, $27, $28, $29, $30, $31, $32, $33,
$34, $35, $36, $37, $38, $39, $40, $41, $42,
$43, $44, $45, $46, $47, $48, $49, $50, $51,
$52, $53, $54, $55, $56, $57, $58, $59, $60,
$61, $62, $63, $64, $65, $66, $67, $68, $69,
$70, $71, $72, $73, $74, $75, $76, $77, $78,
$79, $80, $81, $82, $83, $84, $85, $86, $87,
$88, $89, $90, $91, $92, $93, $94, $95, $96,
$97, $98, $99, $100, $101, $102, $103, $104, $105,
$106, $107, $108, $109, $110, $111, $112, $113, $114,
$115, $116, $117, $118, $119, $120, $121, $122, $123,
$124, $125, $126, $127, $128, $129, $130, $131, $132,
$133, $134, $135, $136, $137, $138, $139, $140, $141
)
RETURNING *
"#,
)
.bind(&slug)
.bind(&req.name)
.bind(&req.description)
.bind(req.is_public)
.bind(&req.thumbnail_path)
.bind(created_by)
// Skin layer
.bind(req.l_skin_0)
.bind(req.l_skin_1)
.bind(req.l_skin_2)
.bind(req.l_skin_3)
.bind(req.l_skin_4)
.bind(req.l_skin_5)
.bind(req.l_skin_6)
.bind(req.l_skin_7)
.bind(req.l_skin_8)
// Clothes layer
.bind(req.l_clothes_0)
.bind(req.l_clothes_1)
.bind(req.l_clothes_2)
.bind(req.l_clothes_3)
.bind(req.l_clothes_4)
.bind(req.l_clothes_5)
.bind(req.l_clothes_6)
.bind(req.l_clothes_7)
.bind(req.l_clothes_8)
// Accessories layer
.bind(req.l_accessories_0)
.bind(req.l_accessories_1)
.bind(req.l_accessories_2)
.bind(req.l_accessories_3)
.bind(req.l_accessories_4)
.bind(req.l_accessories_5)
.bind(req.l_accessories_6)
.bind(req.l_accessories_7)
.bind(req.l_accessories_8)
// Neutral emotion
.bind(req.e_neutral_0)
.bind(req.e_neutral_1)
.bind(req.e_neutral_2)
.bind(req.e_neutral_3)
.bind(req.e_neutral_4)
.bind(req.e_neutral_5)
.bind(req.e_neutral_6)
.bind(req.e_neutral_7)
.bind(req.e_neutral_8)
// Happy emotion
.bind(req.e_happy_0)
.bind(req.e_happy_1)
.bind(req.e_happy_2)
.bind(req.e_happy_3)
.bind(req.e_happy_4)
.bind(req.e_happy_5)
.bind(req.e_happy_6)
.bind(req.e_happy_7)
.bind(req.e_happy_8)
// Sad emotion
.bind(req.e_sad_0)
.bind(req.e_sad_1)
.bind(req.e_sad_2)
.bind(req.e_sad_3)
.bind(req.e_sad_4)
.bind(req.e_sad_5)
.bind(req.e_sad_6)
.bind(req.e_sad_7)
.bind(req.e_sad_8)
// Angry emotion
.bind(req.e_angry_0)
.bind(req.e_angry_1)
.bind(req.e_angry_2)
.bind(req.e_angry_3)
.bind(req.e_angry_4)
.bind(req.e_angry_5)
.bind(req.e_angry_6)
.bind(req.e_angry_7)
.bind(req.e_angry_8)
// Surprised emotion
.bind(req.e_surprised_0)
.bind(req.e_surprised_1)
.bind(req.e_surprised_2)
.bind(req.e_surprised_3)
.bind(req.e_surprised_4)
.bind(req.e_surprised_5)
.bind(req.e_surprised_6)
.bind(req.e_surprised_7)
.bind(req.e_surprised_8)
// Thinking emotion
.bind(req.e_thinking_0)
.bind(req.e_thinking_1)
.bind(req.e_thinking_2)
.bind(req.e_thinking_3)
.bind(req.e_thinking_4)
.bind(req.e_thinking_5)
.bind(req.e_thinking_6)
.bind(req.e_thinking_7)
.bind(req.e_thinking_8)
// Laughing emotion
.bind(req.e_laughing_0)
.bind(req.e_laughing_1)
.bind(req.e_laughing_2)
.bind(req.e_laughing_3)
.bind(req.e_laughing_4)
.bind(req.e_laughing_5)
.bind(req.e_laughing_6)
.bind(req.e_laughing_7)
.bind(req.e_laughing_8)
// Crying emotion
.bind(req.e_crying_0)
.bind(req.e_crying_1)
.bind(req.e_crying_2)
.bind(req.e_crying_3)
.bind(req.e_crying_4)
.bind(req.e_crying_5)
.bind(req.e_crying_6)
.bind(req.e_crying_7)
.bind(req.e_crying_8)
// Love emotion
.bind(req.e_love_0)
.bind(req.e_love_1)
.bind(req.e_love_2)
.bind(req.e_love_3)
.bind(req.e_love_4)
.bind(req.e_love_5)
.bind(req.e_love_6)
.bind(req.e_love_7)
.bind(req.e_love_8)
// Confused emotion
.bind(req.e_confused_0)
.bind(req.e_confused_1)
.bind(req.e_confused_2)
.bind(req.e_confused_3)
.bind(req.e_confused_4)
.bind(req.e_confused_5)
.bind(req.e_confused_6)
.bind(req.e_confused_7)
.bind(req.e_confused_8)
// Sleeping emotion
.bind(req.e_sleeping_0)
.bind(req.e_sleeping_1)
.bind(req.e_sleeping_2)
.bind(req.e_sleeping_3)
.bind(req.e_sleeping_4)
.bind(req.e_sleeping_5)
.bind(req.e_sleeping_6)
.bind(req.e_sleeping_7)
.bind(req.e_sleeping_8)
// Wink emotion
.bind(req.e_wink_0)
.bind(req.e_wink_1)
.bind(req.e_wink_2)
.bind(req.e_wink_3)
.bind(req.e_wink_4)
.bind(req.e_wink_5)
.bind(req.e_wink_6)
.bind(req.e_wink_7)
.bind(req.e_wink_8)
.fetch_one(executor)
.await?;
Ok(avatar)
}
/// Update a server avatar.
pub async fn update_server_avatar<'e>(
executor: impl PgExecutor<'e>,
avatar_id: Uuid,
req: &UpdateServerAvatarRequest,
) -> Result<ServerAvatar, AppError> {
let avatar = sqlx::query_as::<_, ServerAvatar>(
r#"
UPDATE server.avatars SET
name = COALESCE($2, name),
description = COALESCE($3, description),
is_public = COALESCE($4, is_public),
is_active = COALESCE($5, is_active),
thumbnail_path = COALESCE($6, thumbnail_path),
l_skin_0 = COALESCE($7, l_skin_0),
l_skin_1 = COALESCE($8, l_skin_1),
l_skin_2 = COALESCE($9, l_skin_2),
l_skin_3 = COALESCE($10, l_skin_3),
l_skin_4 = COALESCE($11, l_skin_4),
l_skin_5 = COALESCE($12, l_skin_5),
l_skin_6 = COALESCE($13, l_skin_6),
l_skin_7 = COALESCE($14, l_skin_7),
l_skin_8 = COALESCE($15, l_skin_8),
l_clothes_0 = COALESCE($16, l_clothes_0),
l_clothes_1 = COALESCE($17, l_clothes_1),
l_clothes_2 = COALESCE($18, l_clothes_2),
l_clothes_3 = COALESCE($19, l_clothes_3),
l_clothes_4 = COALESCE($20, l_clothes_4),
l_clothes_5 = COALESCE($21, l_clothes_5),
l_clothes_6 = COALESCE($22, l_clothes_6),
l_clothes_7 = COALESCE($23, l_clothes_7),
l_clothes_8 = COALESCE($24, l_clothes_8),
l_accessories_0 = COALESCE($25, l_accessories_0),
l_accessories_1 = COALESCE($26, l_accessories_1),
l_accessories_2 = COALESCE($27, l_accessories_2),
l_accessories_3 = COALESCE($28, l_accessories_3),
l_accessories_4 = COALESCE($29, l_accessories_4),
l_accessories_5 = COALESCE($30, l_accessories_5),
l_accessories_6 = COALESCE($31, l_accessories_6),
l_accessories_7 = COALESCE($32, l_accessories_7),
l_accessories_8 = COALESCE($33, l_accessories_8),
e_neutral_0 = COALESCE($34, e_neutral_0),
e_neutral_1 = COALESCE($35, e_neutral_1),
e_neutral_2 = COALESCE($36, e_neutral_2),
e_neutral_3 = COALESCE($37, e_neutral_3),
e_neutral_4 = COALESCE($38, e_neutral_4),
e_neutral_5 = COALESCE($39, e_neutral_5),
e_neutral_6 = COALESCE($40, e_neutral_6),
e_neutral_7 = COALESCE($41, e_neutral_7),
e_neutral_8 = COALESCE($42, e_neutral_8),
e_happy_0 = COALESCE($43, e_happy_0),
e_happy_1 = COALESCE($44, e_happy_1),
e_happy_2 = COALESCE($45, e_happy_2),
e_happy_3 = COALESCE($46, e_happy_3),
e_happy_4 = COALESCE($47, e_happy_4),
e_happy_5 = COALESCE($48, e_happy_5),
e_happy_6 = COALESCE($49, e_happy_6),
e_happy_7 = COALESCE($50, e_happy_7),
e_happy_8 = COALESCE($51, e_happy_8),
e_sad_0 = COALESCE($52, e_sad_0),
e_sad_1 = COALESCE($53, e_sad_1),
e_sad_2 = COALESCE($54, e_sad_2),
e_sad_3 = COALESCE($55, e_sad_3),
e_sad_4 = COALESCE($56, e_sad_4),
e_sad_5 = COALESCE($57, e_sad_5),
e_sad_6 = COALESCE($58, e_sad_6),
e_sad_7 = COALESCE($59, e_sad_7),
e_sad_8 = COALESCE($60, e_sad_8),
e_angry_0 = COALESCE($61, e_angry_0),
e_angry_1 = COALESCE($62, e_angry_1),
e_angry_2 = COALESCE($63, e_angry_2),
e_angry_3 = COALESCE($64, e_angry_3),
e_angry_4 = COALESCE($65, e_angry_4),
e_angry_5 = COALESCE($66, e_angry_5),
e_angry_6 = COALESCE($67, e_angry_6),
e_angry_7 = COALESCE($68, e_angry_7),
e_angry_8 = COALESCE($69, e_angry_8),
e_surprised_0 = COALESCE($70, e_surprised_0),
e_surprised_1 = COALESCE($71, e_surprised_1),
e_surprised_2 = COALESCE($72, e_surprised_2),
e_surprised_3 = COALESCE($73, e_surprised_3),
e_surprised_4 = COALESCE($74, e_surprised_4),
e_surprised_5 = COALESCE($75, e_surprised_5),
e_surprised_6 = COALESCE($76, e_surprised_6),
e_surprised_7 = COALESCE($77, e_surprised_7),
e_surprised_8 = COALESCE($78, e_surprised_8),
e_thinking_0 = COALESCE($79, e_thinking_0),
e_thinking_1 = COALESCE($80, e_thinking_1),
e_thinking_2 = COALESCE($81, e_thinking_2),
e_thinking_3 = COALESCE($82, e_thinking_3),
e_thinking_4 = COALESCE($83, e_thinking_4),
e_thinking_5 = COALESCE($84, e_thinking_5),
e_thinking_6 = COALESCE($85, e_thinking_6),
e_thinking_7 = COALESCE($86, e_thinking_7),
e_thinking_8 = COALESCE($87, e_thinking_8),
e_laughing_0 = COALESCE($88, e_laughing_0),
e_laughing_1 = COALESCE($89, e_laughing_1),
e_laughing_2 = COALESCE($90, e_laughing_2),
e_laughing_3 = COALESCE($91, e_laughing_3),
e_laughing_4 = COALESCE($92, e_laughing_4),
e_laughing_5 = COALESCE($93, e_laughing_5),
e_laughing_6 = COALESCE($94, e_laughing_6),
e_laughing_7 = COALESCE($95, e_laughing_7),
e_laughing_8 = COALESCE($96, e_laughing_8),
e_crying_0 = COALESCE($97, e_crying_0),
e_crying_1 = COALESCE($98, e_crying_1),
e_crying_2 = COALESCE($99, e_crying_2),
e_crying_3 = COALESCE($100, e_crying_3),
e_crying_4 = COALESCE($101, e_crying_4),
e_crying_5 = COALESCE($102, e_crying_5),
e_crying_6 = COALESCE($103, e_crying_6),
e_crying_7 = COALESCE($104, e_crying_7),
e_crying_8 = COALESCE($105, e_crying_8),
e_love_0 = COALESCE($106, e_love_0),
e_love_1 = COALESCE($107, e_love_1),
e_love_2 = COALESCE($108, e_love_2),
e_love_3 = COALESCE($109, e_love_3),
e_love_4 = COALESCE($110, e_love_4),
e_love_5 = COALESCE($111, e_love_5),
e_love_6 = COALESCE($112, e_love_6),
e_love_7 = COALESCE($113, e_love_7),
e_love_8 = COALESCE($114, e_love_8),
e_confused_0 = COALESCE($115, e_confused_0),
e_confused_1 = COALESCE($116, e_confused_1),
e_confused_2 = COALESCE($117, e_confused_2),
e_confused_3 = COALESCE($118, e_confused_3),
e_confused_4 = COALESCE($119, e_confused_4),
e_confused_5 = COALESCE($120, e_confused_5),
e_confused_6 = COALESCE($121, e_confused_6),
e_confused_7 = COALESCE($122, e_confused_7),
e_confused_8 = COALESCE($123, e_confused_8),
e_sleeping_0 = COALESCE($124, e_sleeping_0),
e_sleeping_1 = COALESCE($125, e_sleeping_1),
e_sleeping_2 = COALESCE($126, e_sleeping_2),
e_sleeping_3 = COALESCE($127, e_sleeping_3),
e_sleeping_4 = COALESCE($128, e_sleeping_4),
e_sleeping_5 = COALESCE($129, e_sleeping_5),
e_sleeping_6 = COALESCE($130, e_sleeping_6),
e_sleeping_7 = COALESCE($131, e_sleeping_7),
e_sleeping_8 = COALESCE($132, e_sleeping_8),
e_wink_0 = COALESCE($133, e_wink_0),
e_wink_1 = COALESCE($134, e_wink_1),
e_wink_2 = COALESCE($135, e_wink_2),
e_wink_3 = COALESCE($136, e_wink_3),
e_wink_4 = COALESCE($137, e_wink_4),
e_wink_5 = COALESCE($138, e_wink_5),
e_wink_6 = COALESCE($139, e_wink_6),
e_wink_7 = COALESCE($140, e_wink_7),
e_wink_8 = COALESCE($141, e_wink_8),
updated_at = now()
WHERE id = $1
RETURNING *
"#,
)
.bind(avatar_id)
.bind(&req.name)
.bind(&req.description)
.bind(req.is_public)
.bind(req.is_active)
.bind(&req.thumbnail_path)
// Skin layer
.bind(req.l_skin_0)
.bind(req.l_skin_1)
.bind(req.l_skin_2)
.bind(req.l_skin_3)
.bind(req.l_skin_4)
.bind(req.l_skin_5)
.bind(req.l_skin_6)
.bind(req.l_skin_7)
.bind(req.l_skin_8)
// Clothes layer
.bind(req.l_clothes_0)
.bind(req.l_clothes_1)
.bind(req.l_clothes_2)
.bind(req.l_clothes_3)
.bind(req.l_clothes_4)
.bind(req.l_clothes_5)
.bind(req.l_clothes_6)
.bind(req.l_clothes_7)
.bind(req.l_clothes_8)
// Accessories layer
.bind(req.l_accessories_0)
.bind(req.l_accessories_1)
.bind(req.l_accessories_2)
.bind(req.l_accessories_3)
.bind(req.l_accessories_4)
.bind(req.l_accessories_5)
.bind(req.l_accessories_6)
.bind(req.l_accessories_7)
.bind(req.l_accessories_8)
// Neutral emotion
.bind(req.e_neutral_0)
.bind(req.e_neutral_1)
.bind(req.e_neutral_2)
.bind(req.e_neutral_3)
.bind(req.e_neutral_4)
.bind(req.e_neutral_5)
.bind(req.e_neutral_6)
.bind(req.e_neutral_7)
.bind(req.e_neutral_8)
// Happy emotion
.bind(req.e_happy_0)
.bind(req.e_happy_1)
.bind(req.e_happy_2)
.bind(req.e_happy_3)
.bind(req.e_happy_4)
.bind(req.e_happy_5)
.bind(req.e_happy_6)
.bind(req.e_happy_7)
.bind(req.e_happy_8)
// Sad emotion
.bind(req.e_sad_0)
.bind(req.e_sad_1)
.bind(req.e_sad_2)
.bind(req.e_sad_3)
.bind(req.e_sad_4)
.bind(req.e_sad_5)
.bind(req.e_sad_6)
.bind(req.e_sad_7)
.bind(req.e_sad_8)
// Angry emotion
.bind(req.e_angry_0)
.bind(req.e_angry_1)
.bind(req.e_angry_2)
.bind(req.e_angry_3)
.bind(req.e_angry_4)
.bind(req.e_angry_5)
.bind(req.e_angry_6)
.bind(req.e_angry_7)
.bind(req.e_angry_8)
// Surprised emotion
.bind(req.e_surprised_0)
.bind(req.e_surprised_1)
.bind(req.e_surprised_2)
.bind(req.e_surprised_3)
.bind(req.e_surprised_4)
.bind(req.e_surprised_5)
.bind(req.e_surprised_6)
.bind(req.e_surprised_7)
.bind(req.e_surprised_8)
// Thinking emotion
.bind(req.e_thinking_0)
.bind(req.e_thinking_1)
.bind(req.e_thinking_2)
.bind(req.e_thinking_3)
.bind(req.e_thinking_4)
.bind(req.e_thinking_5)
.bind(req.e_thinking_6)
.bind(req.e_thinking_7)
.bind(req.e_thinking_8)
// Laughing emotion
.bind(req.e_laughing_0)
.bind(req.e_laughing_1)
.bind(req.e_laughing_2)
.bind(req.e_laughing_3)
.bind(req.e_laughing_4)
.bind(req.e_laughing_5)
.bind(req.e_laughing_6)
.bind(req.e_laughing_7)
.bind(req.e_laughing_8)
// Crying emotion
.bind(req.e_crying_0)
.bind(req.e_crying_1)
.bind(req.e_crying_2)
.bind(req.e_crying_3)
.bind(req.e_crying_4)
.bind(req.e_crying_5)
.bind(req.e_crying_6)
.bind(req.e_crying_7)
.bind(req.e_crying_8)
// Love emotion
.bind(req.e_love_0)
.bind(req.e_love_1)
.bind(req.e_love_2)
.bind(req.e_love_3)
.bind(req.e_love_4)
.bind(req.e_love_5)
.bind(req.e_love_6)
.bind(req.e_love_7)
.bind(req.e_love_8)
// Confused emotion
.bind(req.e_confused_0)
.bind(req.e_confused_1)
.bind(req.e_confused_2)
.bind(req.e_confused_3)
.bind(req.e_confused_4)
.bind(req.e_confused_5)
.bind(req.e_confused_6)
.bind(req.e_confused_7)
.bind(req.e_confused_8)
// Sleeping emotion
.bind(req.e_sleeping_0)
.bind(req.e_sleeping_1)
.bind(req.e_sleeping_2)
.bind(req.e_sleeping_3)
.bind(req.e_sleeping_4)
.bind(req.e_sleeping_5)
.bind(req.e_sleeping_6)
.bind(req.e_sleeping_7)
.bind(req.e_sleeping_8)
// Wink emotion
.bind(req.e_wink_0)
.bind(req.e_wink_1)
.bind(req.e_wink_2)
.bind(req.e_wink_3)
.bind(req.e_wink_4)
.bind(req.e_wink_5)
.bind(req.e_wink_6)
.bind(req.e_wink_7)
.bind(req.e_wink_8)
.fetch_one(executor)
.await?;
Ok(avatar)
}
/// Delete a server avatar.
pub async fn delete_server_avatar<'e>(
executor: impl PgExecutor<'e>,
avatar_id: Uuid,
) -> Result<(), AppError> {
sqlx::query(
r#"
DELETE FROM server.avatars
WHERE id = $1
"#,
)
.bind(avatar_id)
.execute(executor)
.await?;
Ok(())
}

View file

@ -3,7 +3,7 @@
use sqlx::{PgConnection, PgPool};
use uuid::Uuid;
use crate::models::{StaffMember, User, UserWithAuth};
use crate::models::{AgeCategory, GenderPreference, StaffMember, User, UserWithAuth};
use chattyness_error::AppError;
/// Get a user by their ID.
@ -17,6 +17,9 @@ pub async fn get_user_by_id(pool: &PgPool, id: Uuid) -> Result<Option<User>, App
display_name,
bio,
avatar_url,
birthday,
gender_preference,
age_category,
reputation_tier,
status,
email_verified,
@ -45,6 +48,9 @@ pub async fn get_user_by_username(pool: &PgPool, username: &str) -> Result<Optio
display_name,
bio,
avatar_url,
birthday,
gender_preference,
age_category,
reputation_tier,
status,
email_verified,
@ -73,6 +79,9 @@ pub async fn get_user_by_email(pool: &PgPool, email: &str) -> Result<Option<User
display_name,
bio,
avatar_url,
birthday,
gender_preference,
age_category,
reputation_tier,
status,
email_verified,
@ -182,6 +191,9 @@ pub async fn get_user_by_session(
u.display_name,
u.bio,
u.avatar_url,
u.birthday,
u.gender_preference,
u.age_category,
u.reputation_tier,
u.status,
u.email_verified,
@ -440,6 +452,20 @@ pub async fn create_user_conn(
email: Option<&str>,
display_name: &str,
password: &str,
) -> Result<Uuid, AppError> {
create_user_with_preferences_conn(conn, username, email, display_name, password, None, None, None).await
}
/// Create a new user with preferences using a connection (for RLS support).
pub async fn create_user_with_preferences_conn(
conn: &mut sqlx::PgConnection,
username: &str,
email: Option<&str>,
display_name: &str,
password: &str,
birthday: Option<chrono::NaiveDate>,
gender_preference: Option<GenderPreference>,
age_category: Option<AgeCategory>,
) -> Result<Uuid, AppError> {
use argon2::{
Argon2, PasswordHasher,
@ -455,8 +481,11 @@ pub async fn create_user_conn(
let (user_id,): (Uuid,) = sqlx::query_as(
r#"
INSERT INTO auth.users (username, email, password_hash, display_name, auth_provider, status)
VALUES ($1, $2, $3, $4, 'local', 'active')
INSERT INTO auth.users (
username, email, password_hash, display_name, auth_provider, status,
birthday, gender_preference, age_category
)
VALUES ($1, $2, $3, $4, 'local', 'active', $5, COALESCE($6, 'gender_neutral'), COALESCE($7, 'adult'))
RETURNING id
"#,
)
@ -464,6 +493,9 @@ pub async fn create_user_conn(
.bind(email)
.bind(&password_hash)
.bind(display_name)
.bind(birthday)
.bind(gender_preference)
.bind(age_category)
.fetch_one(conn)
.await?;
@ -590,3 +622,97 @@ pub async fn create_guest_user(pool: &PgPool, guest_name: &str) -> Result<Uuid,
Ok(user_id)
}
/// Update a user's preferences (birthday, gender_preference, age_category).
pub async fn update_user_preferences(
pool: &PgPool,
user_id: Uuid,
birthday: Option<chrono::NaiveDate>,
gender_preference: Option<crate::models::GenderPreference>,
age_category: Option<crate::models::AgeCategory>,
) -> Result<(), AppError> {
// Build dynamic update based on provided fields
let mut set_clauses = vec!["updated_at = now()".to_string()];
if birthday.is_some() {
set_clauses.push(format!("birthday = ${}", set_clauses.len() + 1));
}
if gender_preference.is_some() {
set_clauses.push(format!("gender_preference = ${}", set_clauses.len() + 1));
}
if age_category.is_some() {
set_clauses.push(format!("age_category = ${}", set_clauses.len() + 1));
}
if set_clauses.len() == 1 {
// Only updated_at, nothing to update
return Ok(());
}
let query = format!(
"UPDATE auth.users SET {} WHERE id = $1",
set_clauses.join(", ")
);
let mut q = sqlx::query(&query).bind(user_id);
if let Some(b) = birthday {
q = q.bind(b);
}
if let Some(g) = gender_preference {
q = q.bind(g);
}
if let Some(a) = age_category {
q = q.bind(a);
}
q.execute(pool).await?;
Ok(())
}
/// Update a user's preferences using a connection (for RLS support).
pub async fn update_user_preferences_conn(
conn: &mut PgConnection,
user_id: Uuid,
birthday: Option<chrono::NaiveDate>,
gender_preference: Option<crate::models::GenderPreference>,
age_category: Option<crate::models::AgeCategory>,
) -> Result<(), AppError> {
// Build dynamic update based on provided fields
let mut set_clauses = vec!["updated_at = now()".to_string()];
if birthday.is_some() {
set_clauses.push(format!("birthday = ${}", set_clauses.len() + 1));
}
if gender_preference.is_some() {
set_clauses.push(format!("gender_preference = ${}", set_clauses.len() + 1));
}
if age_category.is_some() {
set_clauses.push(format!("age_category = ${}", set_clauses.len() + 1));
}
if set_clauses.len() == 1 {
// Only updated_at, nothing to update
return Ok(());
}
let query = format!(
"UPDATE auth.users SET {} WHERE id = $1",
set_clauses.join(", ")
);
let mut q = sqlx::query(&query).bind(user_id);
if let Some(b) = birthday {
q = q.bind(b);
}
if let Some(g) = gender_preference {
q = q.bind(g);
}
if let Some(a) = age_category {
q = q.bind(a);
}
q.execute(conn).await?;
Ok(())
}

View file

@ -5,7 +5,7 @@
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, LooseProp};
use crate::models::{AvatarRenderData, ChannelMemberInfo, ChannelMemberWithAvatar, ForcedAvatarReason, LooseProp};
/// Default function for serde that returns true (for is_same_scene field).
/// Must be pub for serde derive macro to access via full path.
@ -274,4 +274,26 @@ pub enum ServerMessage {
/// Whether the member is still a guest.
is_guest: bool,
},
/// A user's avatar was forcibly changed (by moderator or scene entry).
AvatarForced {
/// User ID whose avatar was forced.
user_id: Uuid,
/// The forced avatar render data.
avatar: AvatarRenderData,
/// Why the avatar was forced.
reason: ForcedAvatarReason,
/// Display name of who forced the avatar (if mod command).
forced_by: Option<String>,
},
/// A user's forced avatar was cleared (returned to their chosen avatar).
AvatarCleared {
/// User ID whose forced avatar was cleared.
user_id: Uuid,
/// The user's original avatar render data (restored).
avatar: AvatarRenderData,
/// Display name of who cleared the forced avatar (if mod command).
cleared_by: Option<String>,
},
}