use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::AppError; use crate::models::common::{DeviceType, PresenceStatus}; use crate::models::users::{UserBlock, UserFollow, UserPresence}; use crate::service::UserService; use crate::session::Session; use super::util::ensure_affected; #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] pub struct UpdatePresenceParams { pub status: PresenceStatus, pub custom_status_text: Option, pub custom_status_emoji: Option, pub device_type: Option, pub ip_address: Option, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] struct UserPresenceRow { id: Uuid, user_id: Uuid, status: PresenceStatus, custom_status_text: Option, custom_status_emoji: Option, device_type: Option, ip_address: Option, last_active_at: DateTime, last_seen_at: Option>, created_at: DateTime, updated_at: DateTime, } impl From for UserPresence { fn from(row: UserPresenceRow) -> Self { Self { id: row.id, user_id: row.user_id, status: row.status, custom_status_text: row.custom_status_text, custom_status_emoji: row.custom_status_emoji, device_type: row.device_type, ip_address: row.ip_address, last_active_at: row.last_active_at, last_seen_at: row.last_seen_at, created_at: row.created_at, updated_at: row.updated_at, } } } impl UserService { pub async fn user_presence_get(&self, session: &Session) -> Result { let user_uid = session.user().ok_or(AppError::Unauthorized)?; let row = sqlx::query_as::<_, UserPresenceRow>( "SELECT id, user_id, status, custom_status_text, custom_status_emoji, device_type, ip_address, \ last_active_at, last_seen_at, created_at, updated_at \ FROM user_presence WHERE user_id = $1", ) .bind(user_uid) .fetch_optional(self.ctx.db.reader()) .await .map_err(AppError::Database)?; row.map(Into::into) .ok_or_else(|| AppError::NotFound("presence not found".into())) } pub async fn user_presence_update( &self, session: &Session, params: UpdatePresenceParams, ) -> Result { let user_uid = session.user().ok_or(AppError::Unauthorized)?; let id = Uuid::now_v7(); let now = chrono::Utc::now(); let row = sqlx::query_as::<_, UserPresenceRow>( "INSERT INTO user_presence (id, user_id, status, custom_status_text, custom_status_emoji, \ device_type, ip_address, last_active_at, last_seen_at, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NULL, $9, $9) \ ON CONFLICT (user_id) DO UPDATE SET \ status = $3, custom_status_text = $4, custom_status_emoji = $5, \ device_type = $6, ip_address = $7, last_active_at = $8, updated_at = $9 \ RETURNING id, user_id, status, custom_status_text, custom_status_emoji, device_type, ip_address, \ last_active_at, last_seen_at, created_at, updated_at", ) .bind(id) .bind(user_uid) .bind(¶ms.status) .bind(¶ms.custom_status_text) .bind(¶ms.custom_status_emoji) .bind(¶ms.device_type) .bind(¶ms.ip_address) .bind(now) .bind(now) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database)?; Ok(row.into()) } pub async fn user_blocks_list( &self, session: &Session, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = session.user().ok_or(AppError::Unauthorized)?; let limit = limit.clamp(1, 100); let offset = offset.max(0); sqlx::query_as::<_, UserBlock>( "SELECT blocker_id, blocked_id, reason, created_at \ FROM user_block WHERE blocker_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn user_block_create( &self, session: &Session, target_user_id: Uuid, reason: Option, ) -> Result { let user_uid = session.user().ok_or(AppError::Unauthorized)?; if user_uid == target_user_id { return Err(AppError::BadRequest("cannot block yourself".into())); } let now = chrono::Utc::now(); sqlx::query_as::<_, UserBlock>( "INSERT INTO user_block (blocker_id, blocked_id, reason, created_at) \ VALUES ($1, $2, $3, $4) \ RETURNING blocker_id, blocked_id, reason, created_at", ) .bind(user_uid) .bind(target_user_id) .bind(&reason) .bind(now) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database) } pub async fn user_block_delete( &self, session: &Session, target_user_id: Uuid, ) -> Result<(), AppError> { let user_uid = session.user().ok_or(AppError::Unauthorized)?; let result = sqlx::query("DELETE FROM user_block WHERE blocker_id = $1 AND blocked_id = $2") .bind(user_uid) .bind(target_user_id) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "block not found") } pub async fn user_follows_list( &self, session: &Session, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = session.user().ok_or(AppError::Unauthorized)?; let limit = limit.clamp(1, 100); let offset = offset.max(0); sqlx::query_as::<_, UserFollow>( "SELECT follower_id, following_id, created_at \ FROM user_follow WHERE follower_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3", ) .bind(user_uid) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn user_follow_create( &self, session: &Session, target_user_id: Uuid, ) -> Result { let user_uid = session.user().ok_or(AppError::Unauthorized)?; if user_uid == target_user_id { return Err(AppError::BadRequest("cannot follow yourself".into())); } let now = chrono::Utc::now(); sqlx::query_as::<_, UserFollow>( "INSERT INTO user_follow (follower_id, following_id, created_at) \ VALUES ($1, $2, $3) \ RETURNING follower_id, following_id, created_at", ) .bind(user_uid) .bind(target_user_id) .bind(now) .fetch_one(self.ctx.db.writer()) .await .map_err(AppError::Database) } pub async fn user_follow_delete( &self, session: &Session, target_user_id: Uuid, ) -> Result<(), AppError> { let user_uid = session.user().ok_or(AppError::Unauthorized)?; let result = sqlx::query("DELETE FROM user_follow WHERE follower_id = $1 AND following_id = $2") .bind(user_uid) .bind(target_user_id) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "follow not found") } }