188 lines
5.5 KiB
Rust
188 lines
5.5 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use chrono::{Duration, NaiveDate, Utc};
|
|
use db::sqlx;
|
|
use serde::{Deserialize, Serialize};
|
|
use session::Session;
|
|
use utoipa::{IntoParams, ToSchema};
|
|
|
|
use crate::{AppService, error::AppError};
|
|
|
|
const HEATMAP_CACHE_PREFIX: &str = "user:heatmap";
|
|
|
|
#[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 user_chpc(
|
|
&self,
|
|
ctx: &Session,
|
|
query: ContributionHeatmapQuery,
|
|
) -> Result<ContributionHeatmapResponse, AppError> {
|
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
|
let user = self.auth_find_user_by_uid(user_uid).await?;
|
|
self.user_contribution_heatmap_for_user(user.id, user.username, query)
|
|
.await
|
|
}
|
|
|
|
pub async fn user_contribution_heatmap_for_user(
|
|
&self,
|
|
user_uid: uuid::Uuid,
|
|
username: String,
|
|
query: ContributionHeatmapQuery,
|
|
) -> Result<ContributionHeatmapResponse, AppError> {
|
|
let (start_date, end_date) =
|
|
parse_date_range(query.start_date, query.end_date)?;
|
|
let cache_key = build_heatmap_cache_key(user_uid, start_date, end_date);
|
|
|
|
if let Ok(Some(cached)) = self
|
|
.cache
|
|
.get::<ContributionHeatmapResponse>(&cache_key)
|
|
.await
|
|
{
|
|
return Ok(cached);
|
|
}
|
|
|
|
let start_dt = start_date
|
|
.and_hms_opt(0, 0, 0)
|
|
.ok_or(AppError::InternalError)?
|
|
.and_utc();
|
|
let end_dt = end_date
|
|
.and_hms_opt(23, 59, 59)
|
|
.ok_or(AppError::InternalError)?
|
|
.and_utc();
|
|
|
|
let rows = sqlx::query_as::<_, (chrono::DateTime<Utc>,)>(
|
|
"SELECT created_at FROM repo_commit \
|
|
WHERE (author = $1 OR committer = $1) AND created_at >= $2 AND created_at <= $3",
|
|
)
|
|
.bind(user_uid)
|
|
.bind(start_dt)
|
|
.bind(end_dt)
|
|
.fetch_all(self.db.reader())
|
|
.await
|
|
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
|
|
|
|
let mut sparse = HashMap::<String, i64>::new();
|
|
for (created_at,) in &rows {
|
|
let date = created_at.format("%Y-%m-%d").to_string();
|
|
*sparse.entry(date).or_insert(0) += 1;
|
|
}
|
|
|
|
let mut heatmap = Vec::new();
|
|
let mut current = start_date;
|
|
while current <= end_date {
|
|
let date = current.format("%Y-%m-%d").to_string();
|
|
let count = sparse.get(&date).copied().unwrap_or(0) as i32;
|
|
heatmap.push(ContributionHeatmapItem { date, count });
|
|
current += Duration::days(1);
|
|
}
|
|
|
|
let response = ContributionHeatmapResponse {
|
|
username,
|
|
total_contributions: rows.len() as i64,
|
|
heatmap,
|
|
start_date: start_date.format("%Y-%m-%d").to_string(),
|
|
end_date: end_date.format("%Y-%m-%d").to_string(),
|
|
};
|
|
|
|
self.cache
|
|
.set(&cache_key, &response)
|
|
.await
|
|
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
|
|
|
Ok(response)
|
|
}
|
|
|
|
pub async fn user_invalidate_chpc_cache(
|
|
&self,
|
|
ctx: &Session,
|
|
) -> Result<(), AppError> {
|
|
let user_uid = ctx.user().ok_or(AppError::Unauthorized)?;
|
|
self.invalidate_user_heatmap_cache(user_uid).await
|
|
}
|
|
|
|
pub async fn invalidate_user_heatmap_cache(
|
|
&self,
|
|
user_uid: uuid::Uuid,
|
|
) -> Result<(), AppError> {
|
|
let pattern = format!("{}:{}:*", HEATMAP_CACHE_PREFIX, user_uid);
|
|
self.cache
|
|
.delete_pattern(&pattern)
|
|
.await
|
|
.map_err(|e| AppError::InternalServerError(e.to_string()))?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn build_heatmap_cache_key(
|
|
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(
|
|
start_date_str: Option<String>,
|
|
end_date_str: Option<String>,
|
|
) -> Result<(NaiveDate, NaiveDate), AppError> {
|
|
let today = Utc::now().date_naive();
|
|
let one_year_ago = today - Duration::days(365);
|
|
|
|
let start_date = match start_date_str {
|
|
Some(date) => parse_date(&date, "start_date")?,
|
|
None => one_year_ago,
|
|
};
|
|
let end_date = match end_date_str {
|
|
Some(date) => parse_date(&date, "end_date")?,
|
|
None => today,
|
|
};
|
|
|
|
if start_date > end_date {
|
|
return Err(AppError::BadRequest(
|
|
"start_date cannot be later than end_date".to_string(),
|
|
));
|
|
}
|
|
|
|
if (end_date - start_date).num_days() > 730 {
|
|
return Err(AppError::BadRequest(
|
|
"date range cannot exceed 2 years".to_string(),
|
|
));
|
|
}
|
|
|
|
Ok((start_date, end_date))
|
|
}
|
|
|
|
fn parse_date(value: &str, field: &str) -> Result<NaiveDate, AppError> {
|
|
NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| {
|
|
AppError::BadRequest(format!(
|
|
"invalid {field} format, expected YYYY-MM-DD"
|
|
))
|
|
})
|
|
}
|