use crate::AppService; use crate::error::AppError; use chrono::{DateTime, Utc}; use models::projects::{project, project_activity}; use models::users::{user, user_activity_log}; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use utoipa::ToSchema; use uuid::Uuid; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UserActivityItem { pub id: i64, pub activity_type: String, // "auth" | "project" pub action: String, pub title: String, pub resource_type: Option, pub resource_name: Option, pub metadata: Option, pub created_at: DateTime, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct UserActivityResponse { pub items: Vec, pub total: u64, pub page: u64, pub per_page: u64, } #[derive(Debug, Clone, Deserialize, Serialize, utoipa::IntoParams)] pub struct UserActivityQuery { pub page: Option, pub per_page: Option, } impl AppService { pub async fn get_user_activity( &self, context: Session, username: String, query: UserActivityQuery, ) -> Result { let target_user = user::Entity::find() .filter(user::Column::Username.eq(&username)) .one(&self.db) .await? .ok_or(AppError::UserNotFound)?; let current_uid = context.user(); let is_owner = current_uid .map(|uid| uid == target_user.uid) .unwrap_or(false); let page = std::cmp::Ord::max(query.page.unwrap_or(1), 1); let per_page = std::cmp::Ord::min(std::cmp::Ord::max(query.per_page.unwrap_or(20), 1), 100); let offset = (page - 1) * per_page; // User's auth activity log entries let auth_logs: Vec<(i64, String, DateTime, serde_json::Value)> = user_activity_log::Entity::find() .filter(user_activity_log::Column::UserUid.eq(target_user.uid)) .order_by_desc(user_activity_log::Column::CreatedAt) .select_only() .column(user_activity_log::Column::Id) .column(user_activity_log::Column::Action) .column(user_activity_log::Column::CreatedAt) .column(user_activity_log::Column::Details) .into_tuple() .all(&self.db) .await?; // User's project activity (where user is the actor) let proj_activity = project_activity::Entity::find() .filter(project_activity::Column::Actor.eq(target_user.uid)) .order_by_desc(project_activity::Column::CreatedAt) .all(&self.db) .await?; // Privacy filter: non-owners only see public project activity let proj_ids: Vec = proj_activity.iter().map(|a| a.project).collect(); let proj_map: std::collections::HashMap = project::Entity::find() .filter(project::Column::Id.is_in(proj_ids)) .all(&self.db) .await? .into_iter() .map(|p| (p.id, p)) .collect(); // Build combined activity list let mut items: Vec = Vec::new(); // Auth events for (id, action, created_at, metadata) in auth_logs { let title = format_action_title(&action); items.push(UserActivityItem { id, activity_type: "auth".to_string(), action, title, resource_type: None, resource_name: None, metadata: if metadata != serde_json::Value::Null { Some(metadata) } else { None }, created_at, }); } // Project events for activity in proj_activity { // Privacy filter if !is_owner { if let Some(proj) = proj_map.get(&activity.project) { if proj.is_public == false { continue; } } else { continue; } } items.push(UserActivityItem { id: activity.id, activity_type: "project".to_string(), action: activity.event_type, title: activity.title, resource_type: Some("project".to_string()), resource_name: proj_map .get(&activity.project) .map(|p| p.name.clone()), metadata: activity.metadata, created_at: activity.created_at, }); } // Sort by created_at DESC items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); let total = items.len() as u64; let page_items: Vec = items.into_iter().skip(offset as usize).take(per_page as usize).collect(); Ok(UserActivityResponse { items: page_items, total, page, per_page, }) } } fn format_action_title(action: &str) -> String { match action { "login" => "Signed in".to_string(), "logout" => "Signed out".to_string(), "register" => "Created account".to_string(), "password_change" => "Changed password".to_string(), "password_reset" => "Reset password".to_string(), "2fa_enabled" => "Enabled 2FA".to_string(), "2fa_disabled" => "Disabled 2FA".to_string(), "ssh_key_add" => "Added SSH key".to_string(), "ssh_key_delete" => "Removed SSH key".to_string(), "ssh_key_update" => "Updated SSH key".to_string(), "ssh_key_revoke" => "Revoked SSH key".to_string(), "access_key_create" => "Created access token".to_string(), "access_key_delete" => "Deleted access token".to_string(), "avatar_upload" => "Updated avatar".to_string(), "profile_update" => "Updated profile".to_string(), _ => format!("Activity: {}", action), } }