use db::{sqlx, sqlx::AssertSqlSafe}; use model::{issues::IssueModel, users::UserModel}; use serde::Deserialize; use session::Session; use super::types::{IssueFilter, IssueResponse, issue_author}; use crate::{AppService, Pagination, error::AppError, session_user}; #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct CreateIssue { pub title: String, pub body: Option, pub priority: Option, pub due_at: Option, } #[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] pub struct UpdateIssue { pub title: Option, pub body: Option, pub priority: Option, pub due_at: Option, } impl AppService { pub async fn issue_create( &self, ctx: &Session, wk_name: &str, params: CreateIssue, ) -> Result { let user_uid = session_user(ctx)?; let wk = self.workspace_resolve(wk_name).await?; self.workspace_require_member(wk.id, user_uid).await?; let title = params.title.trim(); if title.is_empty() { return Err(AppError::BadRequest( "issue title is required".to_string(), )); } let now = chrono::Utc::now(); let id = uuid::Uuid::now_v7(); let priority = params.priority.unwrap_or_else(|| "normal".to_string()); let due_at = params.due_at.and_then(|d| { chrono::DateTime::parse_from_rfc3339(&d) .ok() .map(|dt| dt.to_utc()) }); let mut txn = self.db.begin().await.map_err(|_| AppError::TxnError)?; let issue = sqlx::query_as::<_, IssueModel>( "INSERT INTO issue (id, wk, number, title, body, state, priority, author, due_at, created_at, updated_at) \ VALUES ($1, $2, (SELECT COALESCE(MAX(number), 0) + 1 FROM issue WHERE wk = $2 AND deleted_at IS NULL), \ $3, $4, 'open', $5, $6, $7, $8, $8) \ RETURNING id, wk, number, title, body, state, priority, author, closed_by, closed_at, due_at, created_at, updated_at, deleted_at", ) .bind(id) .bind(wk.id) .bind(title) .bind(¶ms.body) .bind(&priority) .bind(user_uid) .bind(due_at) .bind(now) .fetch_one(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; sqlx::query( "INSERT INTO issue_event (id, issue, actor, event, from_value, to_value, created_at) \ VALUES ($1, $2, $3, 'created', NULL, $4, $5)", ) .bind(uuid::Uuid::now_v7()) .bind(issue.id) .bind(user_uid) .bind(&issue.title) .bind(now) .execute(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; txn.commit().await.map_err(|_| AppError::TxnError)?; let author = self.users_find_by_id(user_uid).await?; Ok(IssueResponse { number: issue.number, title: issue.title, body: issue.body, state: issue.state, priority: issue.priority, due_at: issue.due_at, author: issue_author(author), closed_by: None, closed_at: None, created_at: issue.created_at, updated_at: issue.updated_at, labels: Vec::new(), assignees: Vec::new(), milestone: None, repos: Vec::new(), pull_requests: Vec::new(), }) } pub async fn issue_list( &self, ctx: &Session, wk_name: &str, filter: IssueFilter, pagination: Pagination, ) -> Result, AppError> { let user_uid = session_user(ctx)?; let wk = self.workspace_resolve(wk_name).await?; self.workspace_require_member(wk.id, user_uid).await?; let mut conditions = vec!["i.wk = $1".to_string(), "i.deleted_at IS NULL".to_string()]; let mut param_idx = 2; if filter.state.is_some() { conditions.push(format!("i.state = ${param_idx}")); param_idx += 1; } if filter.priority.is_some() { conditions.push(format!("i.priority = ${param_idx}")); param_idx += 1; } if filter.label.is_some() { conditions.push(format!( "EXISTS(SELECT 1 FROM issue_label il INNER JOIN label l ON l.id = il.label WHERE il.issue = i.id AND l.name = ${param_idx})" )); param_idx += 1; } if filter.milestone.is_some() { conditions.push(format!( "EXISTS(SELECT 1 FROM issue_milestone im INNER JOIN milestone m ON m.id = im.milestone WHERE im.issue = i.id AND m.title = ${param_idx})" )); param_idx += 1; } if filter.assignee.is_some() { conditions.push(format!( "EXISTS(SELECT 1 FROM issue_assignee ia INNER JOIN \"user\" u ON u.id = ia.\"user\" WHERE ia.issue = i.id AND u.username = ${param_idx})" )); param_idx += 1; } let where_clause = conditions.join(" AND "); let limit_idx = param_idx; let offset_idx = param_idx + 1; let query = format!( "SELECT i.id, i.wk, i.number, i.title, i.body, i.state, i.priority, i.author, \ i.closed_by, i.closed_at, i.due_at, i.created_at, i.updated_at, i.deleted_at \ FROM issue i WHERE {where_clause} \ ORDER BY i.created_at DESC LIMIT ${limit_idx} OFFSET ${offset_idx}" ); let mut q = sqlx::query_as::<_, IssueModel>(AssertSqlSafe(query)).bind(wk.id); if let Some(state) = &filter.state { q = q.bind(state); } if let Some(priority) = &filter.priority { q = q.bind(priority); } if let Some(label_name) = &filter.label { q = q.bind(label_name); } if let Some(milestone_title) = &filter.milestone { q = q.bind(milestone_title); } if let Some(assignee_username) = &filter.assignee { q = q.bind(assignee_username); } q = q .bind(pagination.limit() as i64) .bind(pagination.offset() as i64); let issues = q .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; let mut results = Vec::new(); for issue in issues { results.push(self.issue_build_response(issue).await?); } Ok(results) } pub async fn issue_get( &self, ctx: &Session, wk_name: &str, number: i64, ) -> Result { let user_uid = session_user(ctx)?; let wk = self.workspace_resolve(wk_name).await?; self.workspace_require_member(wk.id, user_uid).await?; let issue = self.issue_resolve(wk.id, number).await?; self.issue_build_response(issue).await } pub async fn issue_update( &self, ctx: &Session, wk_name: &str, number: i64, params: UpdateIssue, ) -> Result { let user_uid = session_user(ctx)?; let wk = self.workspace_resolve(wk_name).await?; self.workspace_require_member(wk.id, user_uid).await?; let mut issue = self.issue_resolve(wk.id, number).await?; let now = chrono::Utc::now(); let mut txn = self.db.begin().await.map_err(|_| AppError::TxnError)?; if let Some(title) = ¶ms.title { let title = title.trim(); if title.is_empty() { return Err(AppError::BadRequest( "issue title is required".to_string(), )); } if title != &issue.title { sqlx::query( "INSERT INTO issue_event (id, issue, actor, event, from_value, to_value, created_at) \ VALUES ($1, $2, $3, 'title_changed', $4, $5, $6)", ) .bind(uuid::Uuid::now_v7()) .bind(issue.id) .bind(user_uid) .bind(&issue.title) .bind(title) .bind(now) .execute(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; issue.title = title.to_string(); } } if let Some(priority) = ¶ms.priority { if priority != &issue.priority { sqlx::query( "INSERT INTO issue_event (id, issue, actor, event, from_value, to_value, created_at) \ VALUES ($1, $2, $3, 'priority_changed', $4, $5, $6)", ) .bind(uuid::Uuid::now_v7()) .bind(issue.id) .bind(user_uid) .bind(&issue.priority) .bind(priority) .bind(now) .execute(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; issue.priority = priority.to_string(); } } let next_body = params.body.map(Some).unwrap_or(issue.body.clone()); let next_due_at = params .due_at .and_then(|d| { chrono::DateTime::parse_from_rfc3339(&d) .ok() .map(|dt| dt.to_utc()) }) .or(issue.due_at); issue = sqlx::query_as::<_, IssueModel>( "UPDATE issue SET title = $1, body = $2, priority = $3, due_at = $4, updated_at = $5 WHERE id = $6 \ RETURNING id, wk, number, title, body, state, priority, author, closed_by, closed_at, due_at, created_at, updated_at, deleted_at", ) .bind(&issue.title) .bind(&next_body) .bind(&issue.priority) .bind(next_due_at) .bind(now) .bind(issue.id) .fetch_one(&mut **txn.inner_mut()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; txn.commit().await.map_err(|_| AppError::TxnError)?; self.issue_build_response(issue).await } pub async fn issue_close( &self, ctx: &Session, wk_name: &str, number: i64, ) -> Result { let user_uid = session_user(ctx)?; let wk = self.workspace_resolve(wk_name).await?; self.workspace_require_member(wk.id, user_uid).await?; let issue = self.issue_resolve(wk.id, number).await?; if issue.state == "closed" { return Err(AppError::BadRequest( "issue is already closed".to_string(), )); } let now = chrono::Utc::now(); sqlx::query( "UPDATE issue SET state = 'closed', closed_by = $1, closed_at = $2, updated_at = $2 WHERE id = $3", ) .bind(user_uid) .bind(now) .bind(issue.id) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; sqlx::query( "INSERT INTO issue_event (id, issue, actor, event, from_value, to_value, created_at) \ VALUES ($1, $2, $3, 'closed', $4, 'closed', $5)", ) .bind(uuid::Uuid::now_v7()) .bind(issue.id) .bind(user_uid) .bind(&issue.state) .bind(now) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; let issue = self.issue_resolve(wk.id, number).await?; self.issue_build_response(issue).await } pub async fn issue_reopen( &self, ctx: &Session, wk_name: &str, number: i64, ) -> Result { let user_uid = session_user(ctx)?; let wk = self.workspace_resolve(wk_name).await?; self.workspace_require_member(wk.id, user_uid).await?; let issue = self.issue_resolve(wk.id, number).await?; if issue.state == "open" { return Err(AppError::BadRequest( "issue is already open".to_string(), )); } let now = chrono::Utc::now(); sqlx::query( "UPDATE issue SET state = 'open', closed_by = NULL, closed_at = NULL, updated_at = $1 WHERE id = $2", ) .bind(now) .bind(issue.id) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; sqlx::query( "INSERT INTO issue_event (id, issue, actor, event, from_value, to_value, created_at) \ VALUES ($1, $2, $3, 'reopened', $4, 'open', $5)", ) .bind(uuid::Uuid::now_v7()) .bind(issue.id) .bind(user_uid) .bind(&issue.state) .bind(now) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; let issue = self.issue_resolve(wk.id, number).await?; self.issue_build_response(issue).await } pub async fn issue_delete( &self, ctx: &Session, wk_name: &str, number: i64, ) -> Result<(), AppError> { let user_uid = session_user(ctx)?; let wk = self.workspace_resolve(wk_name).await?; self.workspace_require_admin(wk.id, user_uid).await?; let issue = self.issue_resolve(wk.id, number).await?; sqlx::query("UPDATE issue SET deleted_at = $1 WHERE id = $2") .bind(chrono::Utc::now()) .bind(issue.id) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(()) } pub async fn issue_resolve( &self, wk_id: uuid::Uuid, number: i64, ) -> Result { sqlx::query_as::<_, IssueModel>( "SELECT id, wk, number, title, body, state, priority, author, closed_by, closed_at, due_at, \ created_at, updated_at, deleted_at \ FROM issue WHERE wk = $1 AND number = $2 AND deleted_at IS NULL", ) .bind(wk_id) .bind(number) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or(AppError::IssueNotFound) } async fn issue_build_response( &self, issue: IssueModel, ) -> Result { let author = self.users_find_by_id(issue.author).await?; let closed_by = if let Some(closed_uid) = issue.closed_by { let user = self.users_find_by_id(closed_uid).await?; Some(issue_author(user)) } else { None }; let labels = self.issue_labels(issue.id).await?; let assignees = self.issue_assignees(issue.id).await?; let milestone = self.issue_milestone(issue.id).await?; let repos = self.issue_repos(issue.id).await?; let pull_requests = self.issue_pull_requests(issue.id).await?; Ok(IssueResponse { number: issue.number, title: issue.title, body: issue.body, state: issue.state, priority: issue.priority, due_at: issue.due_at, author: issue_author(author), closed_by, closed_at: issue.closed_at, created_at: issue.created_at, updated_at: issue.updated_at, labels, assignees, milestone, repos, pull_requests, }) } pub async fn users_find_by_id( &self, uid: uuid::Uuid, ) -> Result { sqlx::query_as::<_, UserModel>( "SELECT id, username, display_name, avatar_url, website_url, allow_use, can_search, \ last_sign_in_at, created_at, updated_at \ FROM \"user\" WHERE id = $1 AND allow_use = true", ) .bind(uid) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or(AppError::UserNotFound) } /// CMDK BFF: list issue numbers, titles, states for a workspace. pub async fn issue_list_inner( &self, wk_name: &str, ) -> Result, AppError> { let wk = sqlx::query_as::<_, (uuid::Uuid,)>( "SELECT id FROM workspace WHERE name = $1", ) .bind(wk_name) .fetch_optional(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))? .ok_or_else(|| AppError::NotFound("workspace not found".to_string()))?; let rows = sqlx::query_as::<_, (i32, String, String)>( "SELECT number, title, state FROM issue WHERE wk = $1 AND deleted_at IS NULL ORDER BY updated_at DESC LIMIT 50" ) .bind(wk.0) .fetch_all(self.db.reader()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; Ok(rows) } }