//! Channel member queries for user presence in channels. use sqlx::PgExecutor; use uuid::Uuid; use crate::models::{ChannelMember, ChannelMemberInfo}; use chattyness_error::AppError; /// Join a channel as an authenticated user. /// /// Restores the user's last position if they were previously in the same scene, /// otherwise uses the default position (400, 300). /// Note: channel_id is actually scene_id in this system (scenes are used directly as channels). pub async fn join_channel<'e>( executor: impl PgExecutor<'e>, channel_id: Uuid, user_id: Uuid, ) -> Result { // Note: channel_id is actually scene_id in this system let member = sqlx::query_as::<_, ChannelMember>( r#" INSERT INTO scene.instance_members (instance_id, user_id, position) SELECT $1, $2, COALESCE( -- Try to restore last position if user was in the same scene -- Note: instance_id = scene_id in this system (SELECT m.last_position FROM realm.memberships m JOIN realm.scenes s ON s.id = $1 WHERE m.user_id = $2 AND m.realm_id = s.realm_id AND m.last_scene_id = $1 AND m.last_position IS NOT NULL), -- Default position ST_SetSRID(ST_MakePoint(400, 300), 0) ) ON CONFLICT (instance_id, user_id) DO UPDATE SET joined_at = now() RETURNING id, instance_id as channel_id, user_id, guest_session_id, ST_X(position) as position_x, ST_Y(position) as position_y, facing_direction, is_moving, is_afk, joined_at, last_moved_at "#, ) .bind(channel_id) .bind(user_id) .fetch_one(executor) .await?; Ok(member) } /// Ensure an active avatar exists for a user in a realm. /// 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, realm_id: Uuid, ) -> Result<(), AppError> { sqlx::query( r#" INSERT INTO auth.active_avatars (user_id, realm_id, avatar_id, current_emotion) 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 "#, ) .bind(user_id) .bind(realm_id) .execute(executor) .await?; Ok(()) } /// Leave a channel. /// /// Saves the user's current position to memberships.last_position before removing them. /// Note: channel_id is actually scene_id in this system (scenes are used directly as channels). pub async fn leave_channel<'e>( executor: impl PgExecutor<'e>, channel_id: Uuid, user_id: Uuid, ) -> Result<(), AppError> { // Use data-modifying CTEs with RETURNING to ensure all CTEs execute // Note: channel_id is actually scene_id in this system sqlx::query( r#" WITH member_info AS ( SELECT cm.position, cm.instance_id as scene_id, s.realm_id FROM scene.instance_members cm JOIN realm.scenes s ON cm.instance_id = s.id WHERE cm.instance_id = $1 AND cm.user_id = $2 ), save_position AS ( UPDATE realm.memberships m SET last_position = mi.position, last_scene_id = mi.scene_id, last_visited_at = now() FROM member_info mi WHERE m.realm_id = mi.realm_id AND m.user_id = $2 RETURNING m.user_id ), do_delete AS ( DELETE FROM scene.instance_members WHERE instance_id = $1 AND user_id = $2 RETURNING user_id ) SELECT COUNT(*) FROM save_position, do_delete "#, ) .bind(channel_id) .bind(user_id) .execute(executor) .await?; Ok(()) } /// Update a user's position in a channel. pub async fn update_position<'e>( executor: impl PgExecutor<'e>, channel_id: Uuid, user_id: Uuid, x: f64, y: f64, ) -> Result<(), AppError> { let result = sqlx::query( r#" UPDATE scene.instance_members SET position = ST_SetSRID(ST_MakePoint($3, $4), 0), last_moved_at = now(), is_moving = true WHERE instance_id = $1 AND user_id = $2 "#, ) .bind(channel_id) .bind(user_id) .bind(x) .bind(y) .execute(executor) .await?; if result.rows_affected() == 0 { return Err(AppError::NotFound("Channel member not found".to_string())); } Ok(()) } /// Get all members in a channel with their display info and current emotion. pub async fn get_channel_members<'e>( executor: impl PgExecutor<'e>, channel_id: Uuid, realm_id: Uuid, ) -> Result, AppError> { let members = sqlx::query_as::<_, ChannelMemberInfo>( r#" SELECT cm.id, cm.instance_id as channel_id, cm.user_id, cm.guest_session_id, COALESCE(u.display_name, gs.guest_name, 'Anonymous') as display_name, ST_X(cm.position) as position_x, ST_Y(cm.position) as position_y, cm.facing_direction, cm.is_moving, cm.is_afk, 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 LEFT JOIN auth.users u ON cm.user_id = u.id LEFT JOIN auth.guest_sessions gs ON cm.guest_session_id = gs.id LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $2 WHERE cm.instance_id = $1 ORDER BY cm.joined_at ASC "#, ) .bind(channel_id) .bind(realm_id) .fetch_all(executor) .await?; Ok(members) } /// Get a specific channel member by user ID. pub async fn get_channel_member<'e>( executor: impl PgExecutor<'e>, channel_id: Uuid, user_id: Uuid, realm_id: Uuid, ) -> Result, AppError> { let member = sqlx::query_as::<_, ChannelMemberInfo>( r#" SELECT cm.id, cm.instance_id as channel_id, cm.user_id, cm.guest_session_id, COALESCE(u.display_name, 'Anonymous') as display_name, ST_X(cm.position) as position_x, ST_Y(cm.position) as position_y, cm.facing_direction, cm.is_moving, cm.is_afk, 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 LEFT JOIN auth.users u ON cm.user_id = u.id LEFT JOIN auth.active_avatars aa ON cm.user_id = aa.user_id AND aa.realm_id = $3 WHERE cm.instance_id = $1 AND cm.user_id = $2 "#, ) .bind(channel_id) .bind(user_id) .bind(realm_id) .fetch_optional(executor) .await?; Ok(member) } /// Set a user's moving state to false (called after movement animation completes). pub async fn set_stopped<'e>( executor: impl PgExecutor<'e>, channel_id: Uuid, user_id: Uuid, ) -> Result<(), AppError> { sqlx::query( r#" UPDATE scene.instance_members SET is_moving = false WHERE instance_id = $1 AND user_id = $2 "#, ) .bind(channel_id) .bind(user_id) .execute(executor) .await?; Ok(()) } /// Set a user's AFK state. pub async fn set_afk<'e>( executor: impl PgExecutor<'e>, channel_id: Uuid, user_id: Uuid, is_afk: bool, ) -> Result<(), AppError> { sqlx::query( r#" UPDATE scene.instance_members SET is_afk = $3 WHERE instance_id = $1 AND user_id = $2 "#, ) .bind(channel_id) .bind(user_id) .bind(is_afk) .execute(executor) .await?; Ok(()) } /// Update the last_moved_at timestamp to keep the member alive. /// /// Called when a ping is received from the client to prevent /// the member from being reaped by the stale member cleanup. pub async fn touch_member<'e>( executor: impl PgExecutor<'e>, channel_id: Uuid, user_id: Uuid, ) -> Result<(), AppError> { sqlx::query( r#" UPDATE scene.instance_members SET last_moved_at = now() WHERE instance_id = $1 AND user_id = $2 "#, ) .bind(channel_id) .bind(user_id) .execute(executor) .await?; Ok(()) }