//! Friends, Direct Messages, Game Invites, Player Profiles, and Social Feed. use crate::auth::AuthUser; use crate::error::{AppError, AppResult}; use crate::state::AppState; use axum::extract::{Path, State}; use axum::Json; use scopenet_shared::{DirectMessage, FriendInfo, GameInvite, UserPost, UserProfileView}; use serde::Deserialize; use serde_json::Value; // --------------------------------------------------------------------------- // Friends System // --------------------------------------------------------------------------- pub async fn list_friends( auth: AuthUser, State(state): State, ) -> AppResult>> { let rows: Vec<(String, String, String, String, bool, Option)> = sqlx::query_as( "SELECT (CASE WHEN f.user_uuid = ? THEN f.friend_uuid ELSE f.user_uuid END) as target_uuid, u.username, f.status, f.action_uuid, EXISTS(SELECT 1 FROM server_online so WHERE so.uuid = u.uuid) as online, (SELECT gs.name FROM server_online so JOIN game_servers gs ON gs.id = so.server_id WHERE so.uuid = u.uuid LIMIT 1) as playing_on FROM friendships f JOIN users u ON u.uuid = (CASE WHEN f.user_uuid = ? THEN f.friend_uuid ELSE f.user_uuid END) WHERE f.user_uuid = ? OR f.friend_uuid = ? ORDER BY online DESC, u.username ASC", ) .bind(&auth.uuid) .bind(&auth.uuid) .bind(&auth.uuid) .bind(&auth.uuid) .fetch_all(&state.db) .await?; let list = rows .into_iter() .map(|(uuid, username, status, action_uuid, online, playing_on)| { let final_status = if status == "accepted" { "accepted".to_string() } else if action_uuid == auth.uuid { "pending_outgoing".to_string() } else { "pending_incoming".to_string() }; FriendInfo { uuid, username, status: final_status, online, playing_on, last_seen: None, } }) .collect(); Ok(Json(list)) } #[derive(Deserialize)] pub struct FriendRequestPayload { pub username: Option, pub friend_username: Option, } pub async fn send_friend_request( auth: AuthUser, State(state): State, Json(payload): Json, ) -> AppResult> { let name = payload .username .as_deref() .or(payload.friend_username.as_deref()) .ok_or_else(|| AppError::bad_request("Username is required"))? .trim(); let target: Option<(String, String)> = sqlx::query_as( "SELECT uuid, username FROM users WHERE username = ? COLLATE NOCASE", ) .bind(name) .fetch_optional(&state.db) .await?; let (target_uuid, target_name) = target.ok_or_else(|| AppError::not_found("Player not found"))?; if target_uuid == auth.uuid { return Err(AppError::bad_request("You cannot friend yourself")); } let now = chrono::Utc::now().to_rfc3339(); // Check existing let existing: Option<(i64, String, String)> = sqlx::query_as( "SELECT id, status, action_uuid FROM friendships WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)", ) .bind(&auth.uuid) .bind(&target_uuid) .bind(&target_uuid) .bind(&auth.uuid) .fetch_optional(&state.db) .await?; if let Some((_id, status, action)) = existing { if status == "accepted" { return Err(AppError::bad_request("Already friends with this player")); } if status == "pending" && action != auth.uuid { // Reciprocal request -> Auto-accept! sqlx::query("UPDATE friendships SET status = 'accepted', action_uuid = ?, updated_at = ? WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)") .bind(&auth.uuid) .bind(&now) .bind(&auth.uuid) .bind(&target_uuid) .bind(&target_uuid) .bind(&auth.uuid) .execute(&state.db) .await?; return Ok(Json(serde_json::json!({ "ok": true, "status": "accepted", "friend": target_name }))); } return Err(AppError::bad_request("Friend request is already pending")); } sqlx::query( "INSERT INTO friendships (user_uuid, friend_uuid, status, action_uuid, created_at, updated_at) VALUES (?, ?, 'pending', ?, ?, ?)", ) .bind(&auth.uuid) .bind(&target_uuid) .bind(&auth.uuid) .bind(&now) .bind(&now) .execute(&state.db) .await?; Ok(Json(serde_json::json!({ "ok": true, "status": "pending_outgoing", "friend": target_name }))) } pub async fn accept_friend_request( auth: AuthUser, Path(target_uuid): Path, State(state): State, ) -> AppResult> { let now = chrono::Utc::now().to_rfc3339(); let res = sqlx::query( "UPDATE friendships SET status = 'accepted', updated_at = ? WHERE ((user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)) AND status = 'pending' AND action_uuid <> ?", ) .bind(&now) .bind(&auth.uuid) .bind(&target_uuid) .bind(&target_uuid) .bind(&auth.uuid) .bind(&auth.uuid) .execute(&state.db) .await?; if res.rows_affected() == 0 { return Err(AppError::bad_request("No pending request from this user found")); } Ok(Json(serde_json::json!({ "ok": true }))) } pub async fn remove_friend( auth: AuthUser, Path(target_uuid): Path, State(state): State, ) -> AppResult> { sqlx::query( "DELETE FROM friendships WHERE (user_uuid = ? AND friend_uuid = ?) OR (user_uuid = ? AND friend_uuid = ?)", ) .bind(&auth.uuid) .bind(&target_uuid) .bind(&target_uuid) .bind(&auth.uuid) .execute(&state.db) .await?; Ok(Json(serde_json::json!({ "ok": true }))) } #[derive(Deserialize)] pub struct RespondFriendPayload { pub target_uuid: Option, pub friend_uuid: Option, pub accept: bool, } pub async fn respond_friend_request( auth: AuthUser, State(state): State, Json(payload): Json, ) -> AppResult> { let target = payload .target_uuid .or(payload.friend_uuid) .ok_or_else(|| AppError::bad_request("Friend UUID is required"))?; if payload.accept { accept_friend_request(auth, Path(target), State(state)).await } else { remove_friend(auth, Path(target), State(state)).await } } // --------------------------------------------------------------------------- // Direct Messaging (DMs) // --------------------------------------------------------------------------- pub async fn get_direct_messages( auth: AuthUser, Path(target_uuid): Path, State(state): State, ) -> AppResult>> { let rows: Vec<(i64, String, String, String, String, bool, String)> = sqlx::query_as( "SELECT id, sender_uuid, sender_name, recipient_uuid, content, is_read, created_at FROM direct_messages WHERE (sender_uuid = ? AND recipient_uuid = ?) OR (sender_uuid = ? AND recipient_uuid = ?) ORDER BY id ASC LIMIT 100", ) .bind(&auth.uuid) .bind(&target_uuid) .bind(&target_uuid) .bind(&auth.uuid) .fetch_all(&state.db) .await?; // Mark unread messages sent to me as read sqlx::query( "UPDATE direct_messages SET is_read = 1 WHERE recipient_uuid = ? AND sender_uuid = ? AND is_read = 0", ) .bind(&auth.uuid) .bind(&target_uuid) .execute(&state.db) .await?; let list = rows .into_iter() .map(|(id, suuid, sname, ruuid, content, is_read, created)| DirectMessage { id, sender_uuid: suuid, sender_name: sname, recipient_uuid: ruuid, content, is_read, created_at: created, }) .collect(); Ok(Json(list)) } #[derive(Deserialize)] pub struct SendMessagePayload { pub content: String, } pub async fn send_direct_message( auth: AuthUser, Path(target_uuid): Path, State(state): State, Json(payload): Json, ) -> AppResult> { let content = payload.content.trim(); if content.is_empty() { return Err(AppError::bad_request("Message cannot be empty")); } let now = chrono::Utc::now().to_rfc3339(); let id: i64 = sqlx::query_scalar( "INSERT INTO direct_messages (sender_uuid, sender_name, recipient_uuid, content, is_read, created_at) VALUES (?, ?, ?, ?, 0, ?) RETURNING id", ) .bind(&auth.uuid) .bind(&auth.username) .bind(&target_uuid) .bind(content) .bind(&now) .fetch_one(&state.db) .await?; Ok(Json(DirectMessage { id, sender_uuid: auth.uuid.clone(), sender_name: auth.username.clone(), recipient_uuid: target_uuid, content: content.to_string(), is_read: false, created_at: now, })) } // --------------------------------------------------------------------------- // Game Invites // --------------------------------------------------------------------------- pub async fn list_my_game_invites( auth: AuthUser, State(state): State, ) -> AppResult>> { let rows: Vec<( String, String, String, String, String, String, Option, Option, String, String, String, )> = sqlx::query_as( "SELECT gi.id, gi.sender_uuid, gi.sender_name, gi.recipient_uuid, gi.instance_id, i.name as instance_name, gi.server_id, gs.name as server_name, gi.status, gi.created_at, gi.expires_at FROM game_invites gi JOIN instances i ON i.id = gi.instance_id LEFT JOIN game_servers gs ON gs.id = gi.server_id WHERE gi.recipient_uuid = ? AND gi.status = 'pending' AND gi.expires_at > ? ORDER BY gi.created_at DESC", ) .bind(&auth.uuid) .bind(chrono::Utc::now().to_rfc3339()) .fetch_all(&state.db) .await?; let list = rows .into_iter() .map( |(id, suuid, sname, ruuid, iid, iname, sid, sname_opt, status, cat, eat)| GameInvite { id, sender_uuid: suuid, sender_name: sname, recipient_uuid: ruuid, instance_id: iid, instance_name: iname, server_id: sid, server_name: sname_opt, status, created_at: cat, expires_at: eat, }, ) .collect(); Ok(Json(list)) } #[derive(Deserialize)] pub struct SendInvitePayload { pub recipient_uuid: String, pub instance_id: String, pub server_id: Option, } pub async fn send_game_invite( auth: AuthUser, State(state): State, Json(payload): Json, ) -> AppResult> { let invite_id = format!("inv_{}", uuid::Uuid::new_v4().simple()); let now = chrono::Utc::now(); let expires = now + chrono::Duration::minutes(30); sqlx::query( "INSERT INTO game_invites (id, sender_uuid, sender_name, recipient_uuid, instance_id, server_id, status, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?)", ) .bind(&invite_id) .bind(&auth.uuid) .bind(&auth.username) .bind(payload.recipient_uuid) .bind(payload.instance_id) .bind(payload.server_id) .bind(now.to_rfc3339()) .bind(expires.to_rfc3339()) .execute(&state.db) .await?; Ok(Json(serde_json::json!({ "id": invite_id, "ok": true }))) } #[derive(Deserialize)] pub struct RespondInvitePayload { pub action: String, // "accept" or "decline" } pub async fn respond_game_invite( auth: AuthUser, Path(invite_id): Path, State(state): State, Json(payload): Json, ) -> AppResult> { let status = if payload.action == "accept" { "accepted" } else { "declined" }; sqlx::query( "UPDATE game_invites SET status = ? WHERE id = ? AND recipient_uuid = ?", ) .bind(status) .bind(&invite_id) .bind(&auth.uuid) .execute(&state.db) .await?; Ok(Json(serde_json::json!({ "ok": true, "status": status }))) } // --------------------------------------------------------------------------- // Viewable Player Profiles // --------------------------------------------------------------------------- pub async fn get_player_profile( Path(uuid): Path, State(state): State, ) -> AppResult> { let user_row: Option<(String, String)> = sqlx::query_as( "SELECT uuid, username FROM users WHERE uuid = ?", ) .bind(&uuid) .fetch_optional(&state.db) .await?; let (puuid, username) = user_row.ok_or_else(|| AppError::not_found("Player profile not found"))?; let prof_row: Option<(String, Option, Option, Option)> = sqlx::query_as( "SELECT bio, banner_url, custom_badge, featured_achievement_id FROM user_profiles WHERE uuid = ?", ) .bind(&puuid) .fetch_optional(&state.db) .await?; let (bio, banner_url, custom_badge, feat_ach_id) = prof_row.unwrap_or((String::new(), None, None, None)); // Levels let levels = crate::routes::leveling::get_user_levels_by_uuid(&state, &puuid).await?; // Featured achievement if set let featured_achievement = if let Some(fid) = feat_ach_id { let ach_list = crate::routes::achievements::get_achievements_for_user(&state, &puuid, false).await?; ach_list.into_iter().find(|a| a.id == fid) } else { None }; // Unlocked achievements count let achievements_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM user_achievements WHERE user_uuid = ?", ) .bind(&puuid) .fetch_one(&state.db) .await?; // Recent posts let post_rows: Vec<(i64, String, String, String, Option, i64, String)> = sqlx::query_as( "SELECT id, user_uuid, author_name, content, image_url, likes_count, created_at FROM user_posts WHERE user_uuid = ? ORDER BY id DESC LIMIT 10", ) .bind(&puuid) .fetch_all(&state.db) .await?; let posts = post_rows .into_iter() .map(|(id, u_uuid, aname, content, img, likes, cat)| UserPost { id, user_uuid: u_uuid, author_name: aname, content, image_url: img, likes_count: likes, created_at: cat, }) .collect(); Ok(Json(UserProfileView { uuid: puuid, username, bio, banner_url, custom_badge, title: levels.title, global_level: levels.global_level, global_xp: levels.global_xp, server_levels: levels.server_levels, featured_achievement, achievements_count: achievements_count as usize, posts, })) } #[derive(Deserialize)] pub struct UpdateProfilePayload { pub bio: Option, pub banner_url: Option, pub custom_badge: Option, pub featured_achievement_id: Option, } pub async fn update_my_profile( auth: AuthUser, State(state): State, Json(payload): Json, ) -> AppResult> { let now = chrono::Utc::now().to_rfc3339(); sqlx::query( "INSERT INTO user_profiles (uuid, bio, banner_url, custom_badge, featured_achievement_id, updated_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (uuid) DO UPDATE SET bio = COALESCE(?, bio), banner_url = COALESCE(?, banner_url), custom_badge = COALESCE(?, custom_badge), featured_achievement_id = COALESCE(?, featured_achievement_id), updated_at = excluded.updated_at", ) .bind(&auth.uuid) .bind(payload.bio.clone().unwrap_or_default()) .bind(payload.banner_url.clone()) .bind(payload.custom_badge.clone()) .bind(payload.featured_achievement_id.clone()) .bind(&now) .bind(payload.bio) .bind(payload.banner_url) .bind(payload.custom_badge) .bind(payload.featured_achievement_id) .execute(&state.db) .await?; Ok(Json(serde_json::json!({ "ok": true }))) } // --------------------------------------------------------------------------- // Social Posts Feed // --------------------------------------------------------------------------- #[derive(Deserialize)] pub struct CreatePostInput { pub content: String, pub image_url: Option, } pub async fn create_user_post( auth: AuthUser, State(state): State, Json(payload): Json, ) -> AppResult> { let content = payload.content.trim(); if content.is_empty() { return Err(AppError::bad_request("Post content cannot be empty")); } let now = chrono::Utc::now().to_rfc3339(); let id: i64 = sqlx::query_scalar( "INSERT INTO user_posts (user_uuid, author_name, content, image_url, likes_count, created_at) VALUES (?, ?, ?, ?, 0, ?) RETURNING id", ) .bind(&auth.uuid) .bind(&auth.username) .bind(content) .bind(payload.image_url.as_deref()) .bind(&now) .fetch_one(&state.db) .await?; Ok(Json(UserPost { id, user_uuid: auth.uuid.clone(), author_name: auth.username.clone(), content: content.to_string(), image_url: payload.image_url, likes_count: 0, created_at: now, })) } pub async fn delete_user_post( auth: AuthUser, Path(post_id): Path, State(state): State, ) -> AppResult> { sqlx::query("DELETE FROM user_posts WHERE id = ? AND user_uuid = ?") .bind(post_id) .bind(&auth.uuid) .execute(&state.db) .await?; Ok(Json(serde_json::json!({ "ok": true }))) } pub async fn like_user_post( _auth: AuthUser, Path(post_id): Path, State(state): State, ) -> AppResult> { sqlx::query("UPDATE user_posts SET likes_count = likes_count + 1 WHERE id = ?") .bind(post_id) .execute(&state.db) .await?; Ok(Json(serde_json::json!({ "ok": true }))) }