303 lines
10 KiB
Rust
303 lines
10 KiB
Rust
use db::sqlx;
|
|
use model::issues::MilestoneModel;
|
|
use serde::Deserialize;
|
|
use session::Session;
|
|
|
|
use super::types::{MilestoneResponse, milestone_response};
|
|
use crate::{AppService, error::AppError, session_user};
|
|
|
|
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
|
|
pub struct CreateMilestone {
|
|
pub title: String,
|
|
pub description: Option<String>,
|
|
pub due_at: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
|
|
pub struct UpdateMilestone {
|
|
pub title: Option<String>,
|
|
pub description: Option<String>,
|
|
pub state: Option<String>,
|
|
pub due_at: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)]
|
|
pub struct SetIssueMilestone {
|
|
pub milestone_id: uuid::Uuid,
|
|
}
|
|
|
|
impl AppService {
|
|
pub async fn milestone_create(
|
|
&self,
|
|
ctx: &Session,
|
|
wk_name: &str,
|
|
params: CreateMilestone,
|
|
) -> Result<MilestoneResponse, 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 title = params.title.trim();
|
|
if title.is_empty() {
|
|
return Err(AppError::BadRequest(
|
|
"milestone title is required".to_string(),
|
|
));
|
|
}
|
|
|
|
let id = uuid::Uuid::now_v7();
|
|
let now = chrono::Utc::now();
|
|
let due_at = params.due_at.and_then(|d| {
|
|
chrono::DateTime::parse_from_rfc3339(&d)
|
|
.ok()
|
|
.map(|dt| dt.to_utc())
|
|
});
|
|
|
|
let milestone = sqlx::query_as::<_, MilestoneModel>(
|
|
"INSERT INTO milestone (id, wk, title, description, state, due_at, created_at, updated_at) \
|
|
VALUES ($1, $2, $3, $4, 'open', $5, $6, $6) \
|
|
RETURNING id, wk, title, description, state, due_at, closed_at, created_at, updated_at",
|
|
)
|
|
.bind(id)
|
|
.bind(wk.id)
|
|
.bind(title)
|
|
.bind(¶ms.description)
|
|
.bind(due_at)
|
|
.bind(now)
|
|
.fetch_one(self.db.writer())
|
|
.await
|
|
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
|
|
|
|
Ok(milestone_response(milestone))
|
|
}
|
|
|
|
pub async fn milestone_list(
|
|
&self,
|
|
ctx: &Session,
|
|
wk_name: &str,
|
|
) -> Result<Vec<MilestoneResponse>, 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 milestones = sqlx::query_as::<_, MilestoneModel>(
|
|
"SELECT id, wk, title, description, state, due_at, closed_at, created_at, updated_at \
|
|
FROM milestone WHERE wk = $1 ORDER BY created_at ASC",
|
|
)
|
|
.bind(wk.id)
|
|
.fetch_all(self.db.reader())
|
|
.await
|
|
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
|
|
|
|
Ok(milestones.into_iter().map(milestone_response).collect())
|
|
}
|
|
|
|
pub async fn milestone_update(
|
|
&self,
|
|
ctx: &Session,
|
|
wk_name: &str,
|
|
milestone_id: uuid::Uuid,
|
|
params: UpdateMilestone,
|
|
) -> Result<MilestoneResponse, 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 milestone = sqlx::query_as::<_, MilestoneModel>(
|
|
"SELECT id, wk, title, description, state, due_at, closed_at, created_at, updated_at \
|
|
FROM milestone WHERE id = $1 AND wk = $2",
|
|
)
|
|
.bind(milestone_id)
|
|
.bind(wk.id)
|
|
.fetch_optional(self.db.reader())
|
|
.await
|
|
.map_err(|e| AppError::DatabaseError(e.to_string()))?
|
|
.ok_or(AppError::MilestoneNotFound)?;
|
|
|
|
let title = params.title.unwrap_or_else(|| milestone.title.clone());
|
|
let description = params.description.unwrap_or_else(|| {
|
|
milestone.description.clone().unwrap_or_default()
|
|
});
|
|
let state = params.state.unwrap_or_else(|| milestone.state.clone());
|
|
let due_at = params
|
|
.due_at
|
|
.and_then(|d| {
|
|
chrono::DateTime::parse_from_rfc3339(&d)
|
|
.ok()
|
|
.map(|dt| dt.to_utc())
|
|
})
|
|
.or(milestone.due_at);
|
|
let closed_at = if state == "closed" && milestone.state != "closed" {
|
|
Some(chrono::Utc::now())
|
|
} else {
|
|
milestone.closed_at
|
|
};
|
|
|
|
let now = chrono::Utc::now();
|
|
let updated = sqlx::query_as::<_, MilestoneModel>(
|
|
"UPDATE milestone SET title = $1, description = $2, state = $3, due_at = $4, closed_at = $5, updated_at = $6 \
|
|
WHERE id = $7 \
|
|
RETURNING id, wk, title, description, state, due_at, closed_at, created_at, updated_at",
|
|
)
|
|
.bind(title)
|
|
.bind(description)
|
|
.bind(state)
|
|
.bind(due_at)
|
|
.bind(closed_at)
|
|
.bind(now)
|
|
.bind(milestone_id)
|
|
.fetch_one(self.db.writer())
|
|
.await
|
|
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
|
|
|
|
Ok(milestone_response(updated))
|
|
}
|
|
|
|
pub async fn milestone_delete(
|
|
&self,
|
|
ctx: &Session,
|
|
wk_name: &str,
|
|
milestone_id: uuid::Uuid,
|
|
) -> 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 exists = sqlx::query_scalar::<_, bool>(
|
|
"SELECT EXISTS(SELECT 1 FROM milestone WHERE id = $1 AND wk = $2)",
|
|
)
|
|
.bind(milestone_id)
|
|
.bind(wk.id)
|
|
.fetch_one(self.db.reader())
|
|
.await
|
|
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
|
|
|
|
if !exists {
|
|
return Err(AppError::MilestoneNotFound);
|
|
}
|
|
|
|
sqlx::query("DELETE FROM issue_milestone WHERE milestone = $1")
|
|
.bind(milestone_id)
|
|
.execute(self.db.writer())
|
|
.await
|
|
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
|
|
|
|
sqlx::query("DELETE FROM milestone WHERE id = $1")
|
|
.bind(milestone_id)
|
|
.execute(self.db.writer())
|
|
.await
|
|
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn issue_set_milestone(
|
|
&self,
|
|
ctx: &Session,
|
|
wk_name: &str,
|
|
number: i64,
|
|
params: SetIssueMilestone,
|
|
) -> Result<Option<MilestoneResponse>, 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?;
|
|
|
|
let milestone = sqlx::query_as::<_, MilestoneModel>(
|
|
"SELECT id, wk, title, description, state, due_at, closed_at, created_at, updated_at \
|
|
FROM milestone WHERE id = $1 AND wk = $2",
|
|
)
|
|
.bind(params.milestone_id)
|
|
.bind(wk.id)
|
|
.fetch_optional(self.db.reader())
|
|
.await
|
|
.map_err(|e| AppError::DatabaseError(e.to_string()))?
|
|
.ok_or(AppError::MilestoneNotFound)?;
|
|
|
|
let now = chrono::Utc::now();
|
|
sqlx::query("DELETE FROM issue_milestone WHERE issue = $1")
|
|
.bind(issue.id)
|
|
.execute(self.db.writer())
|
|
.await
|
|
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
|
|
|
|
sqlx::query(
|
|
"INSERT INTO issue_milestone (issue, milestone, created_at) VALUES ($1, $2, $3)",
|
|
)
|
|
.bind(issue.id)
|
|
.bind(milestone.id)
|
|
.bind(now)
|
|
.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, 'milestone_changed', NULL, $4, $5)",
|
|
)
|
|
.bind(uuid::Uuid::now_v7())
|
|
.bind(issue.id)
|
|
.bind(user_uid)
|
|
.bind(&milestone.title)
|
|
.bind(now)
|
|
.execute(self.db.writer())
|
|
.await
|
|
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
|
|
|
|
Ok(Some(milestone_response(milestone)))
|
|
}
|
|
|
|
pub async fn issue_clear_milestone(
|
|
&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_member(wk.id, user_uid).await?;
|
|
let issue = self.issue_resolve(wk.id, number).await?;
|
|
|
|
let old_milestone = self.issue_milestone(issue.id).await?;
|
|
if let Some(old) = &old_milestone {
|
|
sqlx::query("DELETE FROM issue_milestone WHERE issue = $1")
|
|
.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, 'milestone_removed', $4, NULL, $5)",
|
|
)
|
|
.bind(uuid::Uuid::now_v7())
|
|
.bind(issue.id)
|
|
.bind(user_uid)
|
|
.bind(&old.title)
|
|
.bind(chrono::Utc::now())
|
|
.execute(self.db.writer())
|
|
.await
|
|
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn issue_milestone(
|
|
&self,
|
|
issue_id: uuid::Uuid,
|
|
) -> Result<Option<MilestoneResponse>, AppError> {
|
|
let milestone = sqlx::query_as::<_, MilestoneModel>(
|
|
"SELECT m.id, m.wk, m.title, m.description, m.state, m.due_at, m.closed_at, m.created_at, m.updated_at \
|
|
FROM issue_milestone im \
|
|
INNER JOIN milestone m ON m.id = im.milestone \
|
|
WHERE im.issue = $1",
|
|
)
|
|
.bind(issue_id)
|
|
.fetch_optional(self.db.reader())
|
|
.await
|
|
.map_err(|e| AppError::DatabaseError(e.to_string()))?;
|
|
|
|
Ok(milestone.map(milestone_response))
|
|
}
|
|
}
|