322 lines
13 KiB
Rust
322 lines
13 KiB
Rust
use crate::AppService;
|
|
use crate::error::AppError;
|
|
use chrono::{DateTime, Utc};
|
|
use models::ai::ai_token_usage;
|
|
use models::issues::issue;
|
|
use models::projects::{project_activity, project_members};
|
|
use models::repos::repo;
|
|
use models::rooms::{room, room_ai};
|
|
use models::users::user;
|
|
use sea_orm::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use session::Session;
|
|
use uuid::Uuid;
|
|
|
|
/// Aggregated project statistics for dashboard display.
|
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct ProjectStatsResponse {
|
|
pub project_id: Uuid,
|
|
pub project_name: String,
|
|
|
|
// ── Membership ─────────────────────────────────────────────
|
|
pub member_count: i64,
|
|
pub my_role: Option<String>,
|
|
|
|
// ── Repos ──────────────────────────────────────────────────
|
|
pub repo_count: i64,
|
|
|
|
// ── Issues ─────────────────────────────────────────────────
|
|
pub issue_total: i64,
|
|
pub issue_open: i64,
|
|
pub issue_closed: i64,
|
|
|
|
// ── Pull Requests ──────────────────────────────────────────
|
|
pub pr_total: i64,
|
|
pub pr_open: i64,
|
|
pub pr_merged: i64,
|
|
pub pr_closed: i64,
|
|
|
|
// ── Rooms ──────────────────────────────────────────────────
|
|
pub room_count: i64,
|
|
|
|
// ── AI usage ───────────────────────────────────────────────
|
|
pub ai_call_count: i64,
|
|
pub ai_input_tokens: i64,
|
|
pub ai_output_tokens: i64,
|
|
pub ai_cost_usd: Option<String>,
|
|
|
|
// ── Activity breakdown ─────────────────────────────────────
|
|
pub activity_last_30d: Vec<ActivityBreakdownItem>,
|
|
pub recent_activities: Vec<ProjectStatsActivityItem>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct ActivityBreakdownItem {
|
|
pub event_type: String,
|
|
pub count: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
|
pub struct ProjectStatsActivityItem {
|
|
pub id: i64,
|
|
pub event_type: String,
|
|
pub title: String,
|
|
pub actor_name: String,
|
|
pub actor_avatar: Option<String>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl AppService {
|
|
/// Get aggregated project statistics for dashboard display.
|
|
pub async fn project_stats(
|
|
&self,
|
|
ctx: &Session,
|
|
project_name: String,
|
|
) -> Result<ProjectStatsResponse, AppError> {
|
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
|
let project = self.utils_find_project_by_name(project_name).await?;
|
|
self.check_project_access(project.id, user_uid).await?;
|
|
|
|
// ── Member count + role ────────────────────────────────
|
|
let member_count = project_members::Entity::find()
|
|
.filter(project_members::Column::Project.eq(project.id))
|
|
.count(&self.db)
|
|
.await?;
|
|
|
|
let my_role = project_members::Entity::find()
|
|
.filter(project_members::Column::Project.eq(project.id))
|
|
.filter(project_members::Column::User.eq(user_uid))
|
|
.one(&self.db)
|
|
.await?
|
|
.and_then(|m| m.scope_role().ok());
|
|
|
|
// ── Repo count ──────────────────────────────────────────
|
|
let repo_count = repo::Entity::find()
|
|
.filter(repo::Column::Project.eq(project.id))
|
|
.count(&self.db)
|
|
.await?;
|
|
|
|
// ── Issue counts ────────────────────────────────────────
|
|
let issue_total = issue::Entity::find()
|
|
.filter(issue::Column::Project.eq(project.id))
|
|
.count(&self.db)
|
|
.await?;
|
|
|
|
let issue_open = issue::Entity::find()
|
|
.filter(issue::Column::Project.eq(project.id))
|
|
.filter(issue::Column::State.eq("open"))
|
|
.count(&self.db)
|
|
.await?;
|
|
|
|
let issue_closed = issue::Entity::find()
|
|
.filter(issue::Column::Project.eq(project.id))
|
|
.filter(issue::Column::State.eq("closed"))
|
|
.count(&self.db)
|
|
.await?;
|
|
|
|
// ── PR counts ───────────────────────────────────────────
|
|
// Pull requests are linked through repos under this project.
|
|
let project_repo_ids: Vec<Uuid> = repo::Entity::find()
|
|
.filter(repo::Column::Project.eq(project.id))
|
|
.all(&self.db)
|
|
.await?
|
|
.iter()
|
|
.map(|r| r.id)
|
|
.collect();
|
|
|
|
let (pr_total, pr_open, pr_merged, pr_closed) = if project_repo_ids.is_empty() {
|
|
(0i64, 0i64, 0i64, 0i64)
|
|
} else {
|
|
use models::pull_request::pull_request as pr;
|
|
|
|
let pr_total = pr::Entity::find()
|
|
.filter(pr::Column::Repo.is_in(project_repo_ids.clone()))
|
|
.count(&self.db)
|
|
.await?;
|
|
|
|
let pr_open = pr::Entity::find()
|
|
.filter(pr::Column::Repo.is_in(project_repo_ids.clone()))
|
|
.filter(pr::Column::Status.eq("open"))
|
|
.count(&self.db)
|
|
.await?;
|
|
|
|
let pr_merged = pr::Entity::find()
|
|
.filter(pr::Column::Repo.is_in(project_repo_ids.clone()))
|
|
.filter(pr::Column::Status.eq("merged"))
|
|
.count(&self.db)
|
|
.await?;
|
|
|
|
let pr_closed = pr::Entity::find()
|
|
.filter(pr::Column::Repo.is_in(project_repo_ids.clone()))
|
|
.filter(pr::Column::Status.eq("closed"))
|
|
.count(&self.db)
|
|
.await?;
|
|
|
|
(
|
|
pr_total as i64,
|
|
pr_open as i64,
|
|
pr_merged as i64,
|
|
pr_closed as i64,
|
|
)
|
|
};
|
|
|
|
// ── Room count ──────────────────────────────────────────
|
|
let room_count = room::Entity::find()
|
|
.filter(room::Column::Project.eq(project.id))
|
|
.count(&self.db)
|
|
.await?;
|
|
|
|
// ── AI call count ───────────────────────────────────────
|
|
// Sum call_count from all room_ai configs under this project's rooms.
|
|
let project_room_ids: Vec<Uuid> = room::Entity::find()
|
|
.filter(room::Column::Project.eq(project.id))
|
|
.all(&self.db)
|
|
.await?
|
|
.iter()
|
|
.map(|r| r.id)
|
|
.collect();
|
|
|
|
let ai_call_count = if project_room_ids.is_empty() {
|
|
0i64
|
|
} else {
|
|
let room_ai_configs = room_ai::Entity::find()
|
|
.filter(room_ai::Column::Room.is_in(project_room_ids.clone()))
|
|
.all(&self.db)
|
|
.await?;
|
|
room_ai_configs.iter().map(|c| c.call_count).sum::<i64>()
|
|
};
|
|
|
|
// ── AI token usage ──────────────────────────────────────
|
|
// Sum token usage from ai_token_usage records for conversations in this project.
|
|
let ai_conversations = models::ai::ai_conversation::Entity::find()
|
|
.filter(models::ai::ai_conversation::Column::ProjectId.eq(project.id))
|
|
.all(&self.db)
|
|
.await?;
|
|
let conv_ids: Vec<Uuid> = ai_conversations.iter().map(|c| c.id).collect();
|
|
|
|
let (ai_input_tokens, ai_output_tokens, ai_cost_usd) = if conv_ids.is_empty() {
|
|
(0i64, 0i64, None)
|
|
} else {
|
|
// Token usage may also be recorded without conversation_id (room AI)
|
|
// Query by user_id + project's members too, but that's too broad.
|
|
// Instead, aggregate by conversation_id AND room-AI records.
|
|
let token_records = ai_token_usage::Entity::find()
|
|
.filter(ai_token_usage::Column::ConversationId.is_in(conv_ids.clone()))
|
|
.all(&self.db)
|
|
.await?;
|
|
|
|
let input_sum = token_records
|
|
.iter()
|
|
.map(|t| t.input_tokens as i64)
|
|
.sum::<i64>();
|
|
let output_sum = token_records
|
|
.iter()
|
|
.map(|t| t.output_tokens as i64)
|
|
.sum::<i64>();
|
|
let cost_sum: rust_decimal::Decimal =
|
|
token_records.iter().filter_map(|t| t.cost_usd).sum();
|
|
|
|
let cost_str = if cost_sum != rust_decimal::Decimal::ZERO {
|
|
Some(cost_sum.to_string())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
(input_sum, output_sum, cost_str)
|
|
};
|
|
|
|
// ── Activity breakdown last 30 days ─────────────────────
|
|
let thirty_days_ago = Utc::now() - chrono::Duration::days(30);
|
|
let activity_rows = project_activity::Entity::find()
|
|
.filter(project_activity::Column::Project.eq(project.id))
|
|
.filter(project_activity::Column::CreatedAt.gte(thirty_days_ago))
|
|
.filter(project_activity::Column::IsPrivate.eq(false))
|
|
.all(&self.db)
|
|
.await?;
|
|
|
|
// Group by event_type
|
|
let mut breakdown_map: std::collections::HashMap<String, i64> =
|
|
std::collections::HashMap::new();
|
|
for a in &activity_rows {
|
|
*breakdown_map.entry(a.event_type.clone()).or_insert(0) += 1;
|
|
}
|
|
let activity_last_30d: Vec<ActivityBreakdownItem> = breakdown_map
|
|
.into_iter()
|
|
.map(|(event_type, count)| ActivityBreakdownItem { event_type, count })
|
|
.collect();
|
|
|
|
// ── Recent activities (top 10) ──────────────────────────
|
|
let recent = project_activity::Entity::find()
|
|
.filter(project_activity::Column::Project.eq(project.id))
|
|
.filter(project_activity::Column::IsPrivate.eq(false))
|
|
.order_by_desc(project_activity::Column::CreatedAt)
|
|
.limit(10)
|
|
.all(&self.db)
|
|
.await?;
|
|
|
|
// Enrich with actor info
|
|
let actor_ids: Vec<Uuid> = recent.iter().map(|a| a.actor).collect();
|
|
let actor_map: std::collections::HashMap<Uuid, (String, Option<String>)> =
|
|
if actor_ids.is_empty() {
|
|
std::collections::HashMap::new()
|
|
} else {
|
|
let users = user::Entity::find()
|
|
.filter(user::Column::Uid.is_in(actor_ids))
|
|
.all(&self.db)
|
|
.await?;
|
|
users
|
|
.into_iter()
|
|
.map(|u| {
|
|
(
|
|
u.uid,
|
|
(
|
|
u.display_name.or(Some(u.username)).unwrap_or_default(),
|
|
u.avatar_url,
|
|
),
|
|
)
|
|
})
|
|
.collect()
|
|
};
|
|
|
|
let recent_activities: Vec<ProjectStatsActivityItem> = recent
|
|
.into_iter()
|
|
.map(|a| {
|
|
let (actor_name, actor_avatar) = actor_map
|
|
.get(&a.actor)
|
|
.cloned()
|
|
.unwrap_or_else(|| ("Unknown".to_string(), None));
|
|
ProjectStatsActivityItem {
|
|
id: a.id,
|
|
event_type: a.event_type,
|
|
title: a.title,
|
|
actor_name,
|
|
actor_avatar,
|
|
created_at: a.created_at,
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(ProjectStatsResponse {
|
|
project_id: project.id,
|
|
project_name: project.name,
|
|
member_count: member_count as i64,
|
|
my_role: my_role.map(|r| r.to_string()),
|
|
repo_count: repo_count as i64,
|
|
issue_total: issue_total as i64,
|
|
issue_open: issue_open as i64,
|
|
issue_closed: issue_closed as i64,
|
|
pr_total,
|
|
pr_open,
|
|
pr_merged,
|
|
pr_closed,
|
|
room_count: room_count as i64,
|
|
ai_call_count,
|
|
ai_input_tokens,
|
|
ai_output_tokens,
|
|
ai_cost_usd,
|
|
activity_last_30d,
|
|
recent_activities,
|
|
})
|
|
}
|
|
}
|