use crate::AppService; use crate::error::AppError; use models::DateTimeUtc; use models::projects::MemberRole; use models::projects::project_activity; use models::projects::project_members; use models::repos::repo; use models::users::user; use sea_orm::*; use serde::{Deserialize, Serialize}; use serde_json::Value; use session::Session; use uuid::Uuid; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct ActivityParams { pub event_type: Option, pub start_date: Option, pub end_date: Option, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct ActivityLogParams { pub event_type: String, pub title: String, pub repo_id: Option, pub content: Option, pub event_id: Option, pub event_sub_id: Option, pub metadata: Option, pub is_private: bool, } use utoipa::__dev::ComposeSchema; use utoipa::ToSchema; use utoipa::openapi::schema::{ObjectBuilder, Type}; use utoipa::openapi::{KnownFormat, SchemaFormat}; /// Role-based visibility level for an activity. #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] #[serde(rename_all = "snake_case")] pub enum ActivityVisibility { /// Visible to all project members (public activities). Public, /// Visible only to admins and owners (private activities). Private, } #[derive(Deserialize, Serialize, Clone, Debug)] pub struct ActivityLogResponse { pub id: i64, pub project_uid: Uuid, pub repo_uid: Option, pub actor_uid: Uuid, pub event_type: String, pub event_id: Option, pub event_sub_id: Option, pub title: String, pub content: Option, pub metadata: Option, pub is_private: bool, /// Visibility level based on `is_private` flag. pub visibility: ActivityVisibility, pub created_at: DateTimeUtc, } impl ActivityLogResponse { /// Apply role-based content filtering. /// Admins/owners see everything. /// Members see redacted content/metadata for sensitive event types. fn apply_role_filter(self, user_role: &Option) -> ActivityLogResponse { let is_admin = matches!(user_role, Some(MemberRole::Owner) | Some(MemberRole::Admin)); let is_sensitive = matches!( self.event_type.as_str(), "member_role_change" | "member_remove" | "member_invite" | "invitation_cancelled" | "join_request_approve" | "join_request_reject" | "join_request_cancel" ); if !is_admin && is_sensitive { return ActivityLogResponse { content: None, metadata: None, ..self }; } self } } impl ComposeSchema for ActivityLogResponse { fn compose( _: Vec>, ) -> utoipa::openapi::RefOr { utoipa::openapi::RefOr::T(utoipa::openapi::Schema::Object( ObjectBuilder::new() .property( "id", ObjectBuilder::new().schema_type(Type::Integer).format(Some( utoipa::openapi::schema::SchemaFormat::KnownFormat( utoipa::openapi::schema::KnownFormat::Int64, ), )), ) .property( "project_uid", ObjectBuilder::new() .schema_type(Type::String) .format(Some(SchemaFormat::KnownFormat(KnownFormat::Uuid))), ) .property( "repo_uid", ObjectBuilder::new() .schema_type(Type::String) .format(Some(SchemaFormat::KnownFormat(KnownFormat::Uuid))), ) .property( "actor_uid", ObjectBuilder::new() .schema_type(Type::String) .format(Some(SchemaFormat::KnownFormat(KnownFormat::Uuid))), ) .property("event_type", ObjectBuilder::new().schema_type(Type::String)) .property("title", ObjectBuilder::new().schema_type(Type::String)) .property("content", ObjectBuilder::new().schema_type(Type::String)) .property( "is_private", ObjectBuilder::new().schema_type(Type::Boolean), ) .property("visibility", ObjectBuilder::new().schema_type(Type::String)) .property("created_at", ObjectBuilder::new().schema_type(Type::String)) .required("id") .required("project_uid") .required("actor_uid") .required("event_type") .required("title") .required("is_private") .required("visibility") .required("created_at") .into(), )) } } impl ToSchema for ActivityLogResponse {} #[derive(Serialize, Deserialize, Clone, Debug, utoipa::ToSchema)] pub struct ActivityLogListResponse { pub logs: Vec, pub total: u64, pub page: u64, pub per_page: u64, /// The current user's role in the project. pub user_role: Option, } impl From for ActivityLogResponse { fn from(log: project_activity::Model) -> Self { ActivityLogResponse { id: log.id, project_uid: log.project, repo_uid: log.repo, actor_uid: log.actor, event_type: log.event_type, event_id: log.event_id, event_sub_id: log.event_sub_id, title: log.title, content: log.content, metadata: log.metadata, is_private: log.is_private, visibility: if log.is_private { ActivityVisibility::Private } else { ActivityVisibility::Public }, created_at: log.created_at, } } } impl AppService { pub async fn project_log_activity( &self, project_id: Uuid, repo_id: Option, actor_uid: Uuid, params: ActivityLogParams, ) -> Result { let actor_username = user::Entity::find() .filter(user::Column::Uid.eq(actor_uid)) .one(&self.db) .await .ok() .and_then(|u| u.map(|x| x.username)); let repo_name = match repo_id { Some(rid) => repo::Entity::find() .filter(repo::Column::Id.eq(rid)) .one(&self.db) .await .ok() .and_then(|r| r.map(|x| x.repo_name)), None => None, }; // Merge username and repo_name into metadata let metadata = { let mut meta = params.metadata.unwrap_or_default(); if let Some(obj) = meta.as_object_mut() { if let Some(ref username) = actor_username { obj.insert("username".to_string(), serde_json::json!(username)); } if let Some(ref name) = repo_name { obj.insert("repo_name".to_string(), serde_json::json!(name)); } } meta }; let log = project_activity::ActiveModel { project: Set(project_id), repo: Set(repo_id), actor: Set(actor_uid), event_type: Set(params.event_type), event_id: Set(params.event_id), event_sub_id: Set(params.event_sub_id), title: Set(params.title), content: Set(params.content), metadata: Set(Some(metadata)), is_private: Set(params.is_private), created_at: Set(chrono::Utc::now()), ..Default::default() }; let created_log = log .insert(&self.db) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(ActivityLogResponse::from(created_log)) } /// Get project activity feed with pagination and filtering. /// Only returns activities the user has permission to see. pub async fn project_get_activities( &self, project_name: String, page: Option, per_page: Option, params: Option, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let project = self.utils_find_project_by_name(project_name).await?; // Check if user has access to the project self.check_project_access(project.id, user_uid).await?; // Get user's role in the project let user_role = match project_members::Entity::find() .filter(project_members::Column::Project.eq(project.id)) .filter(project_members::Column::User.eq(user_uid)) .one(&self.db) .await { Ok(Some(m)) => m.scope_role().ok(), Ok(None) => None, Err(e) => { slog::warn!( self.logs, "failed to look up project member for activity log: {}", e ); None } }; let is_admin = matches!(user_role, Some(MemberRole::Owner) | Some(MemberRole::Admin)); let page = page.unwrap_or(1); let per_page = per_page.unwrap_or(20); // Build the query let mut query = project_activity::Entity::find() .filter(project_activity::Column::Project.eq(project.id)) .order_by_desc(project_activity::Column::CreatedAt); // Non-admin members can only see public activities if !is_admin { query = query.filter(project_activity::Column::IsPrivate.eq(false)); } // Apply filters if provided if let Some(ref p) = params { if let Some(ref event_type) = p.event_type { query = query.filter(project_activity::Column::EventType.eq(event_type.clone())); } if let Some(ref start_date) = p.start_date { if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(start_date) { query = query.filter( project_activity::Column::CreatedAt.gte(dt.with_timezone(&chrono::Utc)), ); } } if let Some(ref end_date) = p.end_date { if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(end_date) { query = query.filter( project_activity::Column::CreatedAt.lte(dt.with_timezone(&chrono::Utc)), ); } } } let total = query .clone() .count(&self.db) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; let logs = query .paginate(&self.db, per_page) .fetch_page(page - 1) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; // Apply role-based content filtering (redact sensitive fields for non-admin members) let logs: Vec = logs .into_iter() .map(|log| ActivityLogResponse::from(log).apply_role_filter(&user_role)) .collect(); // Enrich metadata with actor username and repo_name let actor_uids: Vec = logs.iter().map(|l| l.actor_uid).collect(); let username_map = if actor_uids.is_empty() { std::collections::HashMap::new() } else { let users = user::Entity::find() .filter(user::Column::Uid.is_in(actor_uids)) .all(&self.db) .await?; users.into_iter().map(|u| (u.uid, u.username)).collect() }; let repo_uids: Vec = logs.iter().filter_map(|l| l.repo_uid).collect(); let repo_name_map = if repo_uids.is_empty() { std::collections::HashMap::new() } else { let repos = repo::Entity::find() .filter(repo::Column::Id.is_in(repo_uids)) .all(&self.db) .await?; repos.into_iter().map(|r| (r.id, r.repo_name)).collect() }; let logs: Vec = logs .into_iter() .map(|mut log| { let meta = log .metadata .get_or_insert_with(|| serde_json::Value::Object(serde_json::Map::new())); if let Some(obj) = meta.as_object_mut() { if let Some(username) = username_map.get(&log.actor_uid) { obj.insert("username".to_string(), serde_json::json!(username)); } if let Some(ref repo_uid) = log.repo_uid { if let Some(repo_name) = repo_name_map.get(repo_uid) { obj.insert("repo_name".to_string(), serde_json::json!(repo_name)); } } } log }) .collect(); Ok(ActivityLogListResponse { logs, total, page, per_page, user_role, }) } /// Get a single activity log by ID. pub async fn project_get_activity( &self, activity_id: i64, ctx: &Session, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let log = project_activity::Entity::find_by_id(activity_id) .one(&self.db) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or(AppError::NotFound("Activity not found".to_string()))?; // Check if user has access to the project self.check_project_access(log.project, user_uid).await?; // Enrich metadata with actor username and repo_name let mut log = ActivityLogResponse::from(log); if let Ok(Some(user_record)) = user::Entity::find() .filter(user::Column::Uid.eq(log.actor_uid)) .one(&self.db) .await { if let Some(ref mut meta) = log.metadata { if let Some(obj) = meta.as_object_mut() { obj.insert( "username".to_string(), serde_json::json!(user_record.username), ); } } else { log.metadata = Some(serde_json::json!({ "username": user_record.username })); } } if let Some(ref repo_uid) = log.repo_uid { if let Ok(Some(repo_record)) = repo::Entity::find() .filter(repo::Column::Id.eq(*repo_uid)) .one(&self.db) .await { if let Some(ref mut meta) = log.metadata { if let Some(obj) = meta.as_object_mut() { obj.insert( "repo_name".to_string(), serde_json::json!(repo_record.repo_name), ); } } else { log.metadata = Some(serde_json::json!({ "repo_name": repo_record.repo_name })); } } } Ok(log) } }