gitdataai/lib/service/user/chpc.rs

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"
))
})
}