use uuid::Uuid; use crate::error::AppError; use crate::models::common::{Role, State}; use crate::models::issues::IssueMilestone; use crate::service::IssueService; use crate::session::Session; use super::util::{clamp_limit_offset, ensure_affected, merge_optional_text, required_text}; #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateMilestoneParams { pub title: String, pub description: Option, pub due_at: Option>, } #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UpdateMilestoneParams { pub title: Option, pub description: Option, pub due_at: Option>, pub state: Option, } impl IssueService { pub async fn issue_milestones( &self, ctx: &Session, wk_name: &str, repo_name: &str, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; let repo = crate::models::repos::Repo::find_by_id(self.ctx.db.reader(), repo_id) .await .map_err(AppError::Database)? .ok_or(AppError::NotFound("repo not found".into()))?; self.ensure_repo_readable(user_uid, &repo).await?; let (limit, offset) = clamp_limit_offset(limit, offset); sqlx::query_as::<_, IssueMilestone>( "SELECT id, repo_id, title, description, state, due_at, closed_at, created_by, \ created_at, updated_at FROM issue_milestone WHERE repo_id = $1 \ ORDER BY state ASC, due_at ASC NULLS LAST LIMIT $2 OFFSET $3", ) .bind(repo_id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn issue_create_milestone( &self, ctx: &Session, wk_name: &str, repo_name: &str, params: CreateMilestoneParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; self.ensure_repo_role(repo_id, user_uid, Role::Member) .await?; let title = required_text(params.title, "title")?; let now = chrono::Utc::now(); sqlx::query_as::<_, IssueMilestone>( "INSERT INTO issue_milestone (id, repo_id, title, description, state, due_at, closed_at, \ created_by, created_at, updated_at) VALUES ($1, $2, $3, $4, 'open', $5, NULL, $6, $7, $7) \ RETURNING id, repo_id, title, description, state, due_at, closed_at, created_by, created_at, updated_at", ) .bind(Uuid::now_v7()).bind(repo_id).bind(&title).bind(params.description.as_deref()) .bind(params.due_at).bind(user_uid).bind(now) .fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database) } pub async fn issue_update_milestone( &self, ctx: &Session, wk_name: &str, repo_name: &str, milestone_id: Uuid, params: UpdateMilestoneParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; self.ensure_repo_role(repo_id, user_uid, Role::Member) .await?; let current = sqlx::query_as::<_, IssueMilestone>( "SELECT id, repo_id, title, description, state, due_at, closed_at, created_by, created_at, updated_at \ FROM issue_milestone WHERE id = $1 AND repo_id = $2", ) .bind(milestone_id).bind(repo_id).fetch_optional(self.ctx.db.reader()).await .map_err(AppError::Database)?.ok_or(AppError::NotFound("milestone not found".into()))?; let title = params.title.unwrap_or(current.title); let description = merge_optional_text(params.description, current.description); let due_at = params.due_at.or(current.due_at); let now = chrono::Utc::now(); let (state, closed_at) = match params.state.as_deref() { Some("closed") if current.state != State::Closed => (State::Closed, Some(now)), Some("open") if current.state != State::Open => (State::Open, None), _ => (current.state, current.closed_at), }; sqlx::query_as::<_, IssueMilestone>( "UPDATE issue_milestone SET title = $1, description = $2, state = $3, due_at = $4, \ closed_at = $5, updated_at = $6 WHERE id = $7 \ RETURNING id, repo_id, title, description, state, due_at, closed_at, created_by, created_at, updated_at", ) .bind(&title).bind(&description).bind(state).bind(due_at).bind(closed_at).bind(now).bind(milestone_id) .fetch_one(self.ctx.db.writer()).await.map_err(AppError::Database) } pub async fn issue_delete_milestone( &self, ctx: &Session, wk_name: &str, repo_name: &str, milestone_id: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let repo_id = self.resolve_repo_id(wk_name, repo_name).await?; self.ensure_repo_role(repo_id, user_uid, Role::Admin) .await?; let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; // Clear milestone_id from all issues that reference this milestone sqlx::query( "UPDATE issue SET milestone_id = NULL, updated_at = $1 WHERE milestone_id = $2", ) .bind(chrono::Utc::now()) .bind(milestone_id) .execute(&mut *txn) .await .map_err(AppError::Database)?; let result = sqlx::query("DELETE FROM issue_milestone WHERE id = $1 AND repo_id = $2") .bind(milestone_id) .bind(repo_id) .execute(&mut *txn) .await .map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "milestone not found")?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(()) } }