gitdataai/libs/service/project/activity.rs
2026-04-14 19:02:01 +08:00

446 lines
16 KiB
Rust

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<String>,
pub start_date: Option<String>,
pub end_date: Option<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)]
pub struct ActivityLogParams {
pub event_type: String,
pub title: String,
pub repo_id: Option<Uuid>,
pub content: Option<String>,
pub event_id: Option<Uuid>,
pub event_sub_id: Option<i64>,
pub metadata: Option<Value>,
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<Uuid>,
pub actor_uid: Uuid,
pub event_type: String,
pub event_id: Option<Uuid>,
pub event_sub_id: Option<i64>,
pub title: String,
pub content: Option<String>,
pub metadata: Option<Value>,
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<MemberRole>) -> 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::Schema>>,
) -> utoipa::openapi::RefOr<utoipa::openapi::Schema> {
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<ActivityLogResponse>,
pub total: u64,
pub page: u64,
pub per_page: u64,
/// The current user's role in the project.
pub user_role: Option<MemberRole>,
}
impl From<project_activity::Model> 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<Uuid>,
actor_uid: Uuid,
params: ActivityLogParams,
) -> Result<ActivityLogResponse, AppError> {
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<u64>,
per_page: Option<u64>,
params: Option<ActivityParams>,
ctx: &Session,
) -> Result<ActivityLogListResponse, AppError> {
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<ActivityLogResponse> = 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<Uuid> = 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<Uuid> = 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<ActivityLogResponse> = 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<ActivityLogResponse, AppError> {
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)
}
}