Files
SCOPENET-MC/panel/server/src/routes/social.rs
T

624 lines
18 KiB
Rust

//! 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<AppState>,
) -> AppResult<Json<Vec<FriendInfo>>> {
let rows: Vec<(String, String, String, String, bool, Option<String>)> = 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<String>,
pub friend_username: Option<String>,
}
pub async fn send_friend_request(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<FriendRequestPayload>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
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<String>,
pub friend_uuid: Option<String>,
pub accept: bool,
}
pub async fn respond_friend_request(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<RespondFriendPayload>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
) -> AppResult<Json<Vec<DirectMessage>>> {
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<String>,
State(state): State<AppState>,
Json(payload): Json<SendMessagePayload>,
) -> AppResult<Json<DirectMessage>> {
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<AppState>,
) -> AppResult<Json<Vec<GameInvite>>> {
let rows: Vec<(
String,
String,
String,
String,
String,
String,
Option<i64>,
Option<String>,
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<i64>,
}
pub async fn send_game_invite(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<SendInvitePayload>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
Json(payload): Json<RespondInvitePayload>,
) -> AppResult<Json<Value>> {
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<String>,
State(state): State<AppState>,
) -> AppResult<Json<UserProfileView>> {
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<String>, Option<String>, Option<String>)> = 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<String>, 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<String>,
pub banner_url: Option<String>,
pub custom_badge: Option<String>,
pub featured_achievement_id: Option<String>,
}
pub async fn update_my_profile(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<UpdateProfilePayload>,
) -> AppResult<Json<Value>> {
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<String>,
}
pub async fn create_user_post(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreatePostInput>,
) -> AppResult<Json<UserPost>> {
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<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
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<i64>,
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
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 })))
}