502 lines
17 KiB
Rust
502 lines
17 KiB
Rust
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<String>,
|
|
pub priority: Option<String>,
|
|
pub due_at: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
|
|
pub struct UpdateIssue {
|
|
pub title: Option<String>,
|
|
pub body: Option<String>,
|
|
pub priority: Option<String>,
|
|
pub due_at: Option<String>,
|
|
}
|
|
|
|
impl AppService {
|
|
pub async fn issue_create(
|
|
&self,
|
|
ctx: &Session,
|
|
wk_name: &str,
|
|
params: CreateIssue,
|
|
) -> Result<IssueResponse, 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 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<Vec<IssueResponse>, 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<IssueResponse, 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 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<IssueResponse, 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 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<IssueResponse, 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 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<IssueResponse, 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 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(crate) async fn issue_resolve(
|
|
&self,
|
|
wk_id: uuid::Uuid,
|
|
number: i64,
|
|
) -> Result<IssueModel, AppError> {
|
|
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<IssueResponse, AppError> {
|
|
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(crate) async fn users_find_by_id(
|
|
&self,
|
|
uid: uuid::Uuid,
|
|
) -> Result<UserModel, AppError> {
|
|
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<Vec<(i32, String, String)>, 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)
|
|
}
|
|
}
|