use uuid::Uuid; use crate::error::AppError; use crate::models::issues::IssueComment; use crate::service::IssueService; use crate::session::Session; use super::util::{clamp_limit_offset, ensure_affected, required_text, set_local_user_id}; #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct CreateCommentParams { pub body: String, pub reply_to_comment_id: Option, } #[derive(serde::Deserialize, serde::Serialize, Clone, Debug, utoipa::ToSchema)] pub struct UpdateCommentParams { pub body: String, } impl IssueService { pub async fn issue_comments( &self, ctx: &Session, wk_name: &str, number: i64, limit: i64, offset: i64, ) -> Result, AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let issue = self.resolve_issue(wk_name, number).await?; let issue_id = issue.id; self.ensure_issue_readable(user_uid, &issue).await?; let (limit, offset) = clamp_limit_offset(limit, offset); sqlx::query_as::<_, IssueComment>( "SELECT id, issue_id, author_id, body, reply_to_comment_id, edited_at, deleted_at, \ created_at, updated_at FROM issue_comment \ WHERE issue_id = $1 AND deleted_at IS NULL ORDER BY created_at ASC LIMIT $2 OFFSET $3", ) .bind(issue_id) .bind(limit) .bind(offset) .fetch_all(self.ctx.db.reader()) .await .map_err(AppError::Database) } pub async fn issue_create_comment( &self, ctx: &Session, wk_name: &str, number: i64, params: CreateCommentParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let issue = self.resolve_issue(wk_name, number).await?; let issue_id = issue.id; self.ensure_issue_readable(user_uid, &issue).await?; if issue.locked { self.ensure_issue_editable(user_uid, &issue).await?; } let body = required_text(params.body, "body")?; let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query(set_local_user_id(user_uid)) .execute(&mut *txn) .await .map_err(AppError::Database)?; let comment = sqlx::query_as::<_, IssueComment>( "INSERT INTO issue_comment (id, issue_id, author_id, body, reply_to_comment_id, \ edited_at, deleted_at, created_at, updated_at) \ VALUES ($1, $2, $3, $4, $5, NULL, NULL, $6, $6) \ RETURNING id, issue_id, author_id, body, reply_to_comment_id, edited_at, deleted_at, \ created_at, updated_at", ) .bind(Uuid::now_v7()) .bind(issue_id) .bind(user_uid) .bind(&body) .bind(params.reply_to_comment_id) .bind(now) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; sqlx::query( "UPDATE issue_stats SET comments_count = comments_count + 1, \ last_commented_at = $1, updated_at = $1 WHERE issue_id = $2", ) .bind(now) .bind(issue_id) .execute(&mut *txn) .await .map_err(AppError::Database)?; let is_subscribed: bool = sqlx::query_scalar( "SELECT EXISTS(SELECT 1 FROM issue_subscriber WHERE issue_id = $1 AND user_id = $2)", ) .bind(issue_id) .bind(user_uid) .fetch_one(&mut *txn) .await .map_err(AppError::Database)?; if !is_subscribed { sqlx::query( "INSERT INTO issue_subscriber (id, issue_id, user_id, reason, muted, created_at, updated_at) \ VALUES ($1, $2, $3, 'participant', false, $4, $4) ON CONFLICT DO NOTHING", ) .bind(Uuid::now_v7()).bind(issue_id).bind(user_uid).bind(now) .execute(&mut *txn).await.map_err(AppError::Database)?; } txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(comment) } pub async fn issue_update_comment( &self, ctx: &Session, wk_name: &str, number: i64, comment_id: Uuid, params: UpdateCommentParams, ) -> Result { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let issue = self.resolve_issue(wk_name, number).await?; let issue_id = issue.id; self.ensure_issue_readable(user_uid, &issue).await?; let body = required_text(params.body, "body")?; let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query(set_local_user_id(user_uid)) .execute(&mut *txn) .await .map_err(AppError::Database)?; let result = sqlx::query_as::<_, IssueComment>( "UPDATE issue_comment SET body = $1, edited_at = $2, updated_at = $2 \ WHERE id = $3 AND issue_id = $4 AND author_id = $5 AND deleted_at IS NULL \ RETURNING id, issue_id, author_id, body, reply_to_comment_id, edited_at, deleted_at, \ created_at, updated_at", ) .bind(&body) .bind(now) .bind(comment_id) .bind(issue_id) .bind(user_uid) .fetch_optional(&mut *txn) .await .map_err(AppError::Database)? .ok_or(AppError::NotFound( "comment not found or not authored by you".into(), ))?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(result) } pub async fn issue_delete_comment( &self, ctx: &Session, wk_name: &str, number: i64, comment_id: Uuid, ) -> Result<(), AppError> { let user_uid = ctx.user().ok_or(AppError::Unauthorized)?; let issue = self.resolve_issue(wk_name, number).await?; let issue_id = issue.id; let comment = sqlx::query_as::<_, IssueComment>( "SELECT id, issue_id, author_id, body, reply_to_comment_id, edited_at, deleted_at, \ created_at, updated_at FROM issue_comment WHERE id = $1 AND issue_id = $2 AND deleted_at IS NULL", ) .bind(comment_id).bind(issue_id).fetch_optional(self.ctx.db.reader()).await .map_err(AppError::Database)?.ok_or(AppError::NotFound("comment not found".into()))?; if comment.author_id != user_uid { self.ensure_issue_admin(user_uid, &issue).await?; } let now = chrono::Utc::now(); let mut txn = self .ctx .db .writer() .begin() .await .map_err(|_| AppError::TxnError)?; sqlx::query(set_local_user_id(user_uid)) .execute(&mut *txn) .await .map_err(AppError::Database)?; let result = sqlx::query( "UPDATE issue_comment SET deleted_at = $1, updated_at = $1 WHERE id = $2 AND deleted_at IS NULL", ) .bind(now).bind(comment_id).execute(&mut *txn).await.map_err(AppError::Database)?; ensure_affected(result.rows_affected(), "comment not found")?; sqlx::query( "UPDATE issue_stats SET comments_count = GREATEST(comments_count - 1, 0), updated_at = $1 WHERE issue_id = $2", ) .bind(now).bind(issue_id).execute(&mut *txn).await.map_err(AppError::Database)?; txn.commit().await.map_err(|_| AppError::TxnError)?; Ok(()) } }