use api::users::UserDto; use models::issue::{stopwatch, tracked_time}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait}; 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 StopwatchDto { pub running: bool, #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, pub issue_id: Uuid, #[serde(skip_serializing_if = "Option::is_none")] pub user: Option, #[serde(skip_serializing_if = "Option::is_none")] pub started_at: Option, #[serde(skip_serializing_if = "Option::is_none")] pub created_at: Option, } #[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)] pub struct StopwatchPath { pub space_name: String, pub board_id: Uuid, pub issue_id: String, } impl IssueService { pub async fn get_stopwatch( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, ) -> Result { lookup::require_issue_access(self, caller, space_name, board_id, issue_id).await?; let sw = stopwatch::Entity::find() .filter(stopwatch::Column::IssueId.eq(issue_id)) .filter(stopwatch::Column::UserId.eq(caller)) .one(self.database.read()) .await?; let Some(sw) = sw else { return Ok(StopwatchDto { running: false, id: None, issue_id, user: None, started_at: None, created_at: None, }); }; let user_map = self.audience.load_user_briefs(&[sw.user_id]).await?; let user = user_map.get(&sw.user_id).cloned(); Ok(StopwatchDto { running: true, id: Some(sw.id), issue_id: sw.issue_id, user, started_at: Some(sw.started_at), created_at: Some(sw.created_at), }) } #[tracing::instrument(name = "issues.start_stopwatch", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id))] pub async fn start_stopwatch( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, ) -> Result { lookup::require_issue_access(self, caller, space_name, board_id, issue_id).await?; let existing = stopwatch::Entity::find() .filter(stopwatch::Column::IssueId.eq(issue_id)) .filter(stopwatch::Column::UserId.eq(caller)) .one(self.database.read()) .await?; if existing.is_some() { return Err(IssueError::Conflict( "stopwatch already running for this issue".to_string(), )); } let now = OffsetDateTime::now_utc(); let row = stopwatch::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(issue_id), user_id: Set(caller), started_at: Set(now), created_at: Set(now), }; let inserted = row.insert(self.database.write()).await?; let user_map = self.audience.load_user_briefs(&[caller]).await?; let user = user_map.get(&caller).cloned(); Ok(StopwatchDto { running: true, id: Some(inserted.id), issue_id: inserted.issue_id, user, started_at: Some(inserted.started_at), created_at: Some(inserted.created_at), }) } #[tracing::instrument(name = "issues.stop_stopwatch", skip_all, fields(caller = %caller, space = %space_name, board_id = %board_id, issue_id = %issue_id))] pub async fn stop_stopwatch( &self, caller: i64, space_name: &str, board_id: Uuid, issue_id: Uuid, ) -> Result<(), IssueError> { lookup::require_issue_access(self, caller, space_name, board_id, issue_id).await?; let sw = stopwatch::Entity::find() .filter(stopwatch::Column::IssueId.eq(issue_id)) .filter(stopwatch::Column::UserId.eq(caller)) .one(self.database.read()) .await? .ok_or(IssueError::NotFound)?; let now = OffsetDateTime::now_utc(); let duration = (now - sw.started_at).whole_seconds().max(0); self.database .primary() .transaction::<_, (), IssueError>(|txn| { Box::pin(async move { stopwatch::Entity::delete_by_id(sw.id).exec(txn).await?; let tt = tracked_time::ActiveModel { id: Set(Uuid::now_v7()), issue_id: Set(issue_id), user_id: Set(caller), seconds: Set(duration), created_at: Set(now), }; tt.insert(txn).await?; Ok(()) }) }) .await?; Ok(()) } }