use api::users::UserDto; use models::issue::tracked_time; use sea_orm::{ ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, Set, }; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; use utoipa::{IntoParams, ToSchema}; use uuid::Uuid; use crate::errors::IssueError; use crate::services::service::IssueService; use crate::share::lookup; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct TrackedTimeDto { pub id: Uuid, pub issue_id: Uuid, pub user: Option, pub seconds: i64, pub created_at: OffsetDateTime, } pub type TrackedTimePage = api::pagination::Page; #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] #[serde(deny_unknown_fields)] pub struct ListTrackedTimeQuery { pub user_id: Option, pub limit: Option, pub offset: Option, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct TrackedTimePath { pub space_name: String, pub board_id: Uuid, pub issue_id: String, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct TrackedTimeIdPath { pub space_name: String, pub board_id: Uuid, pub issue_id: String, pub time_id: Uuid, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct CreateTrackedTimeCmd { pub seconds: i64, } #[derive(Debug, Clone, Deserialize, ToSchema)] pub struct UpdateTrackedTimeCmd { pub seconds: i64, } impl IssueService { #[tracing::instrument(name = "issues.create_tracked_time", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id))] pub async fn create_tracked_time( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, cmd: CreateTrackedTimeCmd, ) -> Result { if cmd.seconds <= 0 { return Err(IssueError::BadRequest( "seconds must be positive".to_string(), )); } lookup::require_issue_access(self, caller, space_name, board_id, issue_id).await?; let row = tracked_time::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(issue_id), user_id: Set(caller), seconds: Set(cmd.seconds), created_at: Set(OffsetDateTime::now_utc()), } .insert(self.database.write()) .await?; let user_map = self.audience.load_user_briefs(&[row.user_id]).await?; Ok(TrackedTimeDto { id: row.id, issue_id: row.issue_id, user: user_map.get(&row.user_id).cloned(), seconds: row.seconds, created_at: row.created_at, }) } #[tracing::instrument(name = "issues.update_tracked_time", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id, time_id = %time_id))] pub async fn update_tracked_time( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, time_id: Uuid, cmd: UpdateTrackedTimeCmd, ) -> Result { if cmd.seconds <= 0 { return Err(IssueError::BadRequest( "seconds must be positive".to_string(), )); } lookup::require_issue_access(self, caller, space_name, board_id, issue_id).await?; let row = tracked_time::Entity::find_by_id(time_id) .filter(tracked_time::Column::IssueId.eq(issue_id)) .one(self.database.write()) .await? .ok_or(IssueError::NotFound)?; if row.user_id != caller { return Err(IssueError::Forbidden); } let mut am: tracked_time::ActiveModel = row.into(); am.seconds = Set(cmd.seconds); let updated = am.update(self.database.write()).await?; let user_map = self.audience.load_user_briefs(&[updated.user_id]).await?; Ok(TrackedTimeDto { id: updated.id, issue_id: updated.issue_id, user: user_map.get(&updated.user_id).cloned(), seconds: updated.seconds, created_at: updated.created_at, }) } #[tracing::instrument(name = "issues.delete_tracked_time", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id, time_id = %time_id))] pub async fn delete_tracked_time( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, time_id: Uuid, ) -> Result<(), IssueError> { lookup::require_issue_access(self, caller, space_name, board_id, issue_id).await?; let row = tracked_time::Entity::find_by_id(time_id) .filter(tracked_time::Column::IssueId.eq(issue_id)) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; if row.user_id != caller { return Err(IssueError::Forbidden); } tracked_time::Entity::delete_by_id(time_id) .exec(self.database.write()) .await?; Ok(()) } pub async fn list_tracked_time( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, q: ListTrackedTimeQuery, ) -> Result { lookup::require_issue_access(self, caller, space_name, board_id, issue_id).await?; let (limit, offset) = self.paginate(q.limit, q.offset)?; let mut select = tracked_time::Entity::find().filter(tracked_time::Column::IssueId.eq(issue_id)); if let Some(uid) = q.user_id { select = select.filter(tracked_time::Column::UserId.eq(uid)); } select = select.order_by_desc(tracked_time::Column::CreatedAt); let total = select.clone().count(self.database.read()).await?; let rows = select .limit(limit) .offset(offset) .all(self.database.read()) .await?; let user_ids: Vec = rows.iter().map(|t| t.user_id).collect(); let user_map = self.audience.load_user_briefs(&user_ids).await?; let items = rows .into_iter() .map(|t| { let user = user_map.get(&t.user_id).cloned(); TrackedTimeDto { id: t.id, issue_id: t.issue_id, user, seconds: t.seconds, created_at: t.created_at, } }) .collect(); Ok(TrackedTimePage { items, total, limit, offset, }) } }