446 lines
16 KiB
Rust
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)
|
|
}
|
|
}
|