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, // ── 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, // ── Activity breakdown ───────────────────────────────────── pub activity_last_30d: Vec, pub recent_activities: Vec, } #[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, pub created_at: DateTime, } impl AppService { /// Get aggregated project statistics for dashboard display. pub async fn project_stats( &self, ctx: &Session, project_name: String, ) -> Result { 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 = 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 = 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::() }; // ── 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 = 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::(); let output_sum = token_records .iter() .map(|t| t.output_tokens as i64) .sum::(); 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 = 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 = 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 = recent.iter().map(|a| a.actor).collect(); let actor_map: std::collections::HashMap)> = 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 = 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, }) } }