use crate::AppService; use crate::error::AppError; use chrono::{Duration, Local, NaiveDate}; use models::repos::repo_commit; use models::users::{user, user_email}; use redis::AsyncCommands; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use utoipa::{IntoParams, ToSchema}; /// Cache key prefix for contribution heatmap const HEATMAP_CACHE_PREFIX: &str = "user:heatmap"; /// Default cache TTL in seconds (5 minutes) const HEATMAP_CACHE_TTL: u64 = 300; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ContributionHeatmapItem { pub date: String, pub count: i32, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct ContributionHeatmapResponse { pub username: String, pub total_contributions: i64, pub heatmap: Vec, pub start_date: String, pub end_date: String, } #[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)] pub struct ContributionHeatmapQuery { pub start_date: Option, pub end_date: Option, } impl AppService { pub async fn get_user_contribution_heatmap( &self, _context: Session, username: String, query: ContributionHeatmapQuery, ) -> Result { let user = user::Entity::find() .filter(user::Column::Username.eq(&username)) .one(&self.db) .await? .ok_or(AppError::UserNotFound)?; let (start_date, end_date) = self.parse_date_range(query.start_date, query.end_date)?; let cache_key = self.build_heatmap_cache_key(&user.uid, start_date, end_date); if let Ok(mut conn) = self.cache.conn().await { if let Ok(cached) = conn.get::<_, String>(cache_key.clone()).await { if let Ok(cached) = serde_json::from_str::(&cached) { return Ok(cached); } } } let emails: Vec = user_email::Entity::find() .filter(user_email::Column::User.eq(user.uid)) .select_only() .column(user_email::Column::Email) .into_tuple::() .all(&self.db) .await?; if emails.is_empty() { let response = ContributionHeatmapResponse { username: user.username.clone(), total_contributions: 0, heatmap: vec![], start_date: start_date.format("%Y-%m-%d").to_string(), end_date: end_date.format("%Y-%m-%d").to_string(), }; return Ok(response); } let start_dt = start_date.and_hms_opt(0, 0, 0).unwrap(); let end_dt = end_date.and_hms_opt(23, 59, 59).unwrap(); let commits: Vec = repo_commit::Entity::find() .filter(repo_commit::Column::AuthorEmail.is_in(emails.clone())) .filter(repo_commit::Column::CreatedAt.gte(start_dt)) .filter(repo_commit::Column::CreatedAt.lte(end_dt)) .all(&self.db) .await?; let mut heatmap_map: std::collections::HashMap = std::collections::HashMap::new(); for commit in &commits { let date_str = commit.created_at.format("%Y-%m-%d").to_string(); *heatmap_map.entry(date_str).or_insert(0) += 1; } let total_contributions = commits.len() as i64; let mut heatmap: Vec = Vec::new(); let mut current = start_date; while current <= end_date { let date_str = current.format("%Y-%m-%d").to_string(); let count = *heatmap_map.get(&date_str).unwrap_or(&0); heatmap.push(ContributionHeatmapItem { date: date_str, count: count as i32, }); current += Duration::days(1); } let response = ContributionHeatmapResponse { username: user.username, total_contributions, heatmap, start_date: start_date.format("%Y-%m-%d").to_string(), end_date: end_date.format("%Y-%m-%d").to_string(), }; if let Ok(mut conn) = self.cache.conn().await { let _: Option<()> = conn .set_ex::( cache_key, serde_json::to_string(&response).unwrap_or_default(), HEATMAP_CACHE_TTL, ) .await .ok(); } Ok(response) } pub async fn invalidate_user_heatmap_cache( &self, user_uid: uuid::Uuid, ) -> Result<(), AppError> { // Invalidate all heatmap cache entries for a user // Delete known date range keys (last 2 years) if let Ok(mut conn) = self.cache.conn().await { let today = Local::now().date_naive(); let two_years_ago = today - Duration::days(730); let mut current = two_years_ago; while current <= today { let key = self.build_heatmap_cache_key(&user_uid, current, current); let _: Option<()> = conn.del::<_, ()>(key).await.ok(); current += Duration::days(1); } } Ok(()) } fn build_heatmap_cache_key( &self, user_uid: &uuid::Uuid, start_date: NaiveDate, end_date: NaiveDate, ) -> String { format!( "{}:{}:{}:{}", HEATMAP_CACHE_PREFIX, user_uid, start_date.format("%Y-%m-%d"), end_date.format("%Y-%m-%d"), ) } fn parse_date_range( &self, start_date_str: Option, end_date_str: Option, ) -> Result<(NaiveDate, NaiveDate), AppError> { let today = Local::now().date_naive(); let one_year_ago = today - Duration::days(365); let start_date = if let Some(date_str) = start_date_str { NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|_| { AppError::NotFound("Invalid start_date format, expected YYYY-MM-DD".to_string()) })? } else { one_year_ago }; let end_date = if let Some(date_str) = end_date_str { NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").map_err(|_| { AppError::NotFound("Invalid end_date format, expected YYYY-MM-DD".to_string()) })? } else { today }; if start_date > end_date { return Err(AppError::NotFound( "start_date cannot be later than end_date".to_string(), )); } if (end_date - start_date).num_days() > 730 { return Err(AppError::NotFound( "Date range cannot exceed 2 years".to_string(), )); } Ok((start_date, end_date)) } pub async fn get_current_user_contribution_heatmap( &self, context: Session, query: ContributionHeatmapQuery, ) -> Result { let user_uid = context.user().ok_or(AppError::Unauthorized)?; let user = user::Entity::find() .filter(user::Column::Uid.eq(user_uid)) .one(&self.db) .await? .ok_or(AppError::UserNotFound)?; self.get_user_contribution_heatmap(context, user.username, query) .await } }