224 lines
7.4 KiB
Rust
224 lines
7.4 KiB
Rust
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<ContributionHeatmapItem>,
|
|
pub start_date: String,
|
|
pub end_date: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, IntoParams)]
|
|
pub struct ContributionHeatmapQuery {
|
|
pub start_date: Option<String>,
|
|
pub end_date: Option<String>,
|
|
}
|
|
|
|
impl AppService {
|
|
pub async fn get_user_contribution_heatmap(
|
|
&self,
|
|
_context: Session,
|
|
username: String,
|
|
query: ContributionHeatmapQuery,
|
|
) -> Result<ContributionHeatmapResponse, AppError> {
|
|
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::<ContributionHeatmapResponse>(&cached) {
|
|
return Ok(cached);
|
|
}
|
|
}
|
|
}
|
|
|
|
let emails: Vec<String> = user_email::Entity::find()
|
|
.filter(user_email::Column::User.eq(user.uid))
|
|
.select_only()
|
|
.column(user_email::Column::Email)
|
|
.into_tuple::<String>()
|
|
.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::Model> = 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<String, i64> =
|
|
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<ContributionHeatmapItem> = 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::<String, String, ()>(
|
|
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<String>,
|
|
end_date_str: Option<String>,
|
|
) -> 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<ContributionHeatmapResponse, AppError> {
|
|
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
|
|
}
|
|
}
|