176 lines
6.1 KiB
Rust
176 lines
6.1 KiB
Rust
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<String>,
|
|
pub resource_name: Option<String>,
|
|
pub metadata: Option<serde_json::Value>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
|
|
pub struct UserActivityResponse {
|
|
pub items: Vec<UserActivityItem>,
|
|
pub total: u64,
|
|
pub page: u64,
|
|
pub per_page: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, Serialize, utoipa::IntoParams)]
|
|
pub struct UserActivityQuery {
|
|
pub page: Option<u64>,
|
|
pub per_page: Option<u64>,
|
|
}
|
|
|
|
impl AppService {
|
|
pub async fn get_user_activity(
|
|
&self,
|
|
context: Session,
|
|
username: String,
|
|
query: UserActivityQuery,
|
|
) -> Result<UserActivityResponse, AppError> {
|
|
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<Utc>, 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<Uuid> = proj_activity.iter().map(|a| a.project).collect();
|
|
let proj_map: std::collections::HashMap<Uuid, project::Model> = 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<UserActivityItem> = 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<UserActivityItem> = 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),
|
|
}
|
|
}
|