feat(lib/issues): add issues crate with handlers, services, share, and openapi
ZhenYi committed
Added lib/issues/src/handlers/boards/create_board.rs lib/issues/src/handlers/boards/create_board.rs +33 −0 View lib/issues/src/handlers/boards/create_board.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::boards::{BoardDto, CreateBoardCmd};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9use space::services::spaces::SpaceNamePath;
10
11#[utoipa::path(
12 post,
13 path = "/issues/{space_name}",
14 tag = "issues",
15 params(SpaceNamePath),
16 request_body = CreateBoardCmd,
17 responses(
18 (status = 200, description = "create board", body = AppResponseData<BoardDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn create_board(
25 State(service): State<IssueService>,
26 session: Session,
27 Path(path): Path<SpaceNamePath>,
28 Json(cmd): Json<CreateBoardCmd>,
29) -> Result<AppResponseData<BoardDto>, ApiError> {
30 let uid = require_uid(&session)?;
31 let dto = service.create_board(uid, &path.space_name, cmd).await?;
32 Ok(AppResponseData::ok(dto))
33}
Added lib/issues/src/handlers/boards/delete_board.rs lib/issues/src/handlers/boards/delete_board.rs +31 −0 View lib/issues/src/handlers/boards/delete_board.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::boards::BoardPath;
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/{space_name}/{board_id}",
13 tag = "issues",
14 params(BoardPath),
15 responses(
16 (status = 200, description = "delete board", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_board(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<BoardPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_board(uid, &path.space_name, path.board_id)
29 .await?;
30 Ok(AppResponseData::ok(NoData))
31}
Added lib/issues/src/handlers/boards/get_board.rs lib/issues/src/handlers/boards/get_board.rs +31 −0 View lib/issues/src/handlers/boards/get_board.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::boards::{BoardDto, BoardPath};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/issues/{space_name}/{board_id}",
13 tag = "issues",
14 params(BoardPath),
15 responses(
16 (status = 200, description = "get board", body = AppResponseData<BoardDto>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn get_board(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<BoardPath>,
25) -> Result<AppResponseData<BoardDto>, ApiError> {
26 let uid = require_uid(&session)?;
27 let dto = service
28 .get_board(uid, &path.space_name, path.board_id)
29 .await?;
30 Ok(AppResponseData::ok(dto))
31}
Added lib/issues/src/handlers/boards/list_boards.rs lib/issues/src/handlers/boards/list_boards.rs +31 −0 View lib/issues/src/handlers/boards/list_boards.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::boards::{BoardPage, ListBoardQuery};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9use space::services::spaces::SpaceNamePath;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}",
14 tag = "issues",
15 params(SpaceNamePath, ListBoardQuery),
16 responses(
17 (status = 200, description = "list boards", body = AppResponseData<BoardPage>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_boards(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<SpaceNamePath>,
26 Query(q): Query<ListBoardQuery>,
27) -> Result<AppResponseData<BoardPage>, ApiError> {
28 let uid = require_uid(&session)?;
29 let page = service.list_boards(uid, &path.space_name, q).await?;
30 Ok(AppResponseData::ok(page))
31}
Added lib/issues/src/handlers/boards/mod.rs lib/issues/src/handlers/boards/mod.rs +5 −0 View lib/issues/src/handlers/boards/mod.rs
@@ -0,0 +1,5 @@
1pub mod create_board;
2pub mod delete_board;
3pub mod get_board;
4pub mod list_boards;
5pub mod update_board;
Added lib/issues/src/handlers/boards/update_board.rs lib/issues/src/handlers/boards/update_board.rs +34 −0 View lib/issues/src/handlers/boards/update_board.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::boards::{BoardDto, BoardPath, UpdateBoardCmd};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 patch,
12 path = "/issues/{space_name}/{board_id}",
13 tag = "issues",
14 params(BoardPath),
15 request_body = UpdateBoardCmd,
16 responses(
17 (status = 200, description = "update board", body = AppResponseData<BoardDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn update_board(
24 State(service): State<IssueService>,
25 session: Session,
26 Path(path): Path<BoardPath>,
27 Json(cmd): Json<UpdateBoardCmd>,
28) -> Result<AppResponseData<BoardDto>, ApiError> {
29 let uid = require_uid(&session)?;
30 let dto = service
31 .update_board(uid, &path.space_name, path.board_id, cmd)
32 .await?;
33 Ok(AppResponseData::ok(dto))
34}
Added lib/issues/src/handlers/comments/create_comment.rs lib/issues/src/handlers/comments/create_comment.rs +35 −0 View lib/issues/src/handlers/comments/create_comment.rs
@@ -0,0 +1,35 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::comments::{CommentDto, CreateCommentCmd};
7use crate::services::issues::IssuePath;
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/issues/{space_name}/{board_id}/{issue_id}/comments",
14 tag = "issues",
15 params(IssuePath),
16 request_body = CreateCommentCmd,
17 responses(
18 (status = 200, description = "create comment", body = AppResponseData<CommentDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn create_comment(
25 State(service): State<IssueService>,
26 session: Session,
27 Path(path): Path<IssuePath>,
28 Json(cmd): Json<CreateCommentCmd>,
29) -> Result<AppResponseData<CommentDto>, ApiError> {
30 let uid = require_uid(&session)?;
31 let dto = service
32 .create_comment(uid, &path.space_name, path.board_id, path.issue_id, cmd)
33 .await?;
34 Ok(AppResponseData::ok(dto))
35}
Added lib/issues/src/handlers/comments/delete_comment.rs lib/issues/src/handlers/comments/delete_comment.rs +37 −0 View lib/issues/src/handlers/comments/delete_comment.rs
@@ -0,0 +1,37 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::comments::CommentPath;
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/comments/{comment_id}",
13 tag = "issues",
14 params(CommentPath),
15 responses(
16 (status = 200, description = "delete comment", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_comment(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<CommentPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_comment(
29 uid,
30 &path.space_name,
31 path.board_id,
32 path.issue_id,
33 path.comment_id,
34 )
35 .await?;
36 Ok(AppResponseData::ok(NoData))
37}
Added lib/issues/src/handlers/comments/get_comment.rs lib/issues/src/handlers/comments/get_comment.rs +37 −0 View lib/issues/src/handlers/comments/get_comment.rs
@@ -0,0 +1,37 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::comments::{CommentDto, CommentPath};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/comments/{comment_id}",
13 tag = "issues",
14 params(CommentPath),
15 responses(
16 (status = 200, description = "get comment", body = AppResponseData<CommentDto>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn get_comment(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<CommentPath>,
25) -> Result<AppResponseData<CommentDto>, ApiError> {
26 let uid = require_uid(&session)?;
27 let dto = service
28 .get_comment(
29 uid,
30 &path.space_name,
31 path.board_id,
32 path.issue_id,
33 path.comment_id,
34 )
35 .await?;
36 Ok(AppResponseData::ok(dto))
37}
Added lib/issues/src/handlers/comments/list_comments.rs lib/issues/src/handlers/comments/list_comments.rs +33 −0 View lib/issues/src/handlers/comments/list_comments.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::comments::{CommentPage, ListCommentQuery};
7use crate::services::issues::IssuePath;
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}/{board_id}/{issue_id}/comments",
14 tag = "issues",
15 params(IssuePath, ListCommentQuery),
16 responses(
17 (status = 200, description = "list comments", body = AppResponseData<CommentPage>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_comments(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<IssuePath>,
26 Query(q): Query<ListCommentQuery>,
27) -> Result<AppResponseData<CommentPage>, ApiError> {
28 let uid = require_uid(&session)?;
29 let page = service
30 .list_comments(uid, &path.space_name, path.board_id, path.issue_id, q)
31 .await?;
32 Ok(AppResponseData::ok(page))
33}
Added lib/issues/src/handlers/comments/mod.rs lib/issues/src/handlers/comments/mod.rs +5 −0 View lib/issues/src/handlers/comments/mod.rs
@@ -0,0 +1,5 @@
1pub mod create_comment;
2pub mod delete_comment;
3pub mod get_comment;
4pub mod list_comments;
5pub mod update_comment;
Added lib/issues/src/handlers/comments/update_comment.rs lib/issues/src/handlers/comments/update_comment.rs +41 −0 View lib/issues/src/handlers/comments/update_comment.rs
@@ -0,0 +1,41 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::comments::{CommentDto, CommentPath, UpdateCommentCmd};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 patch,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/comments/{comment_id}",
13 tag = "issues",
14 params(CommentPath),
15 request_body = UpdateCommentCmd,
16 responses(
17 (status = 200, description = "update comment", body = AppResponseData<CommentDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn update_comment(
24 State(service): State<IssueService>,
25 session: Session,
26 Path(path): Path<CommentPath>,
27 Json(cmd): Json<UpdateCommentCmd>,
28) -> Result<AppResponseData<CommentDto>, ApiError> {
29 let uid = require_uid(&session)?;
30 let dto = service
31 .update_comment(
32 uid,
33 &path.space_name,
34 path.board_id,
35 path.issue_id,
36 path.comment_id,
37 cmd,
38 )
39 .await?;
40 Ok(AppResponseData::ok(dto))
41}
Added lib/issues/src/handlers/content_history/list_content_history.rs lib/issues/src/handlers/content_history/list_content_history.rs +34 −0 View lib/issues/src/handlers/content_history/list_content_history.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::content_history::{
7 ContentHistoryPage, ContentHistoryPath, ListContentHistoryQuery,
8};
9use crate::services::service::IssueService;
10use crate::share::session::require_uid;
11
12#[utoipa::path(
13 get,
14 path = "/issues/{space_name}/{board_id}/{issue_id}/history",
15 tag = "issues",
16 params(ContentHistoryPath, ListContentHistoryQuery),
17 responses(
18 (status = 200, description = "list content history", body = AppResponseData<ContentHistoryPage>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn list_content_history(
24 State(service): State<IssueService>,
25 session: Session,
26 Path(path): Path<ContentHistoryPath>,
27 Query(q): Query<ListContentHistoryQuery>,
28) -> Result<AppResponseData<ContentHistoryPage>, ApiError> {
29 let uid = require_uid(&session)?;
30 let page = service
31 .list_content_history(uid, &path.space_name, path.board_id, path.issue_id, q)
32 .await?;
33 Ok(AppResponseData::ok(page))
34}
Added lib/issues/src/handlers/content_history/mod.rs lib/issues/src/handlers/content_history/mod.rs +1 −0 View lib/issues/src/handlers/content_history/mod.rs
@@ -0,0 +1 @@
1pub mod list_content_history;
Added lib/issues/src/handlers/dependencies/create_dependency.rs lib/issues/src/handlers/dependencies/create_dependency.rs +36 −0 View lib/issues/src/handlers/dependencies/create_dependency.rs
@@ -0,0 +1,36 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::dependencies::{CreateDependencyCmd, DependencyDto};
7use crate::services::issues::IssuePath;
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/issues/{space_name}/{board_id}/{issue_id}/dependencies",
14 tag = "issues",
15 params(IssuePath),
16 request_body = CreateDependencyCmd,
17 responses(
18 (status = 200, description = "create issue dependency", body = AppResponseData<DependencyDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>),
22 (status = 409, description = "conflict", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn create_dependency(
26 State(service): State<IssueService>,
27 session: Session,
28 Path(path): Path<IssuePath>,
29 Json(cmd): Json<CreateDependencyCmd>,
30) -> Result<AppResponseData<DependencyDto>, ApiError> {
31 let uid = require_uid(&session)?;
32 let dto = service
33 .create_dependency(uid, &path.space_name, path.board_id, path.issue_id, cmd)
34 .await?;
35 Ok(AppResponseData::ok(dto))
36}
Added lib/issues/src/handlers/dependencies/delete_dependency.rs lib/issues/src/handlers/dependencies/delete_dependency.rs +37 −0 View lib/issues/src/handlers/dependencies/delete_dependency.rs
@@ -0,0 +1,37 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::dependencies::DependencyPath;
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/dependencies/{dep_id}",
13 tag = "issues",
14 params(DependencyPath),
15 responses(
16 (status = 200, description = "delete issue dependency", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_dependency(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<DependencyPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_dependency(
29 uid,
30 &path.space_name,
31 path.board_id,
32 path.issue_id,
33 path.dep_id,
34 )
35 .await?;
36 Ok(AppResponseData::ok(NoData))
37}
Added lib/issues/src/handlers/dependencies/list_dependencies.rs lib/issues/src/handlers/dependencies/list_dependencies.rs +32 −0 View lib/issues/src/handlers/dependencies/list_dependencies.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::dependencies::DependencyDto;
7use crate::services::issues::IssuePath;
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}/{board_id}/{issue_id}/dependencies",
14 tag = "issues",
15 params(IssuePath),
16 responses(
17 (status = 200, description = "list issue dependencies", body = AppResponseData<Vec<DependencyDto>>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_dependencies(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<IssuePath>,
26) -> Result<AppResponseData<Vec<DependencyDto>>, ApiError> {
27 let uid = require_uid(&session)?;
28 let deps = service
29 .list_dependencies(uid, &path.space_name, path.board_id, path.issue_id)
30 .await?;
31 Ok(AppResponseData::ok(deps))
32}
Added lib/issues/src/handlers/dependencies/mod.rs lib/issues/src/handlers/dependencies/mod.rs +3 −0 View lib/issues/src/handlers/dependencies/mod.rs
@@ -0,0 +1,3 @@
1pub mod create_dependency;
2pub mod delete_dependency;
3pub mod list_dependencies;
Added lib/issues/src/handlers/events/list_events.rs lib/issues/src/handlers/events/list_events.rs +32 −0 View lib/issues/src/handlers/events/list_events.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::events::{EventPage, EventPath, ListEventQuery};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/events",
13 tag = "issues",
14 params(EventPath, ListEventQuery),
15 responses(
16 (status = 200, description = "list issue events", body = AppResponseData<EventPage>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn list_events(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<EventPath>,
25 Query(q): Query<ListEventQuery>,
26) -> Result<AppResponseData<EventPage>, ApiError> {
27 let uid = require_uid(&session)?;
28 let page = service
29 .list_events(uid, &path.space_name, path.board_id, path.issue_id, q)
30 .await?;
31 Ok(AppResponseData::ok(page))
32}
Added lib/issues/src/handlers/events/mod.rs lib/issues/src/handlers/events/mod.rs +1 −0 View lib/issues/src/handlers/events/mod.rs
@@ -0,0 +1 @@
1pub mod list_events;
Added lib/issues/src/handlers/issue_users/list_issue_users.rs lib/issues/src/handlers/issue_users/list_issue_users.rs +33 −0 View lib/issues/src/handlers/issue_users/list_issue_users.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issue_users::{IssueUserPage, ListIssueUserQuery};
7use crate::services::issues::IssuePath;
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}/{board_id}/{issue_id}/users",
14 tag = "issues",
15 params(IssuePath, ListIssueUserQuery),
16 responses(
17 (status = 200, description = "list issue user states", body = AppResponseData<IssueUserPage>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_issue_users(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<IssuePath>,
26 Query(q): Query<ListIssueUserQuery>,
27) -> Result<AppResponseData<IssueUserPage>, ApiError> {
28 let uid = require_uid(&session)?;
29 let page = service
30 .list_issue_users(uid, &path.space_name, path.board_id, path.issue_id, q)
31 .await?;
32 Ok(AppResponseData::ok(page))
33}
Added lib/issues/src/handlers/issue_users/mod.rs lib/issues/src/handlers/issue_users/mod.rs +2 −0 View lib/issues/src/handlers/issue_users/mod.rs
@@ -0,0 +1,2 @@
1pub mod list_issue_users;
2pub mod update_issue_user;
Added lib/issues/src/handlers/issue_users/update_issue_user.rs lib/issues/src/handlers/issue_users/update_issue_user.rs +40 −0 View lib/issues/src/handlers/issue_users/update_issue_user.rs
@@ -0,0 +1,40 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issue_users::{IssueUserDto, IssueUserPath, UpdateIssueUserCmd};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 patch,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/users/{uid}",
13 tag = "issues",
14 params(IssueUserPath),
15 request_body = UpdateIssueUserCmd,
16 responses(
17 (status = 200, description = "update issue user state", body = AppResponseData<IssueUserDto>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn update_issue_user(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<IssueUserPath>,
26 Json(cmd): Json<UpdateIssueUserCmd>,
27) -> Result<AppResponseData<IssueUserDto>, ApiError> {
28 let caller = require_uid(&session)?;
29 let dto = service
30 .update_issue_user(
31 caller,
32 &path.space_name,
33 path.board_id,
34 path.issue_id,
35 path.uid,
36 cmd,
37 )
38 .await?;
39 Ok(AppResponseData::ok(dto))
40}
Added lib/issues/src/handlers/issues/close_issue.rs lib/issues/src/handlers/issues/close_issue.rs +31 −0 View lib/issues/src/handlers/issues/close_issue.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issues::{IssueDto, IssuePath};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 post,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/close",
13 tag = "issues",
14 params(IssuePath),
15 responses(
16 (status = 200, description = "close issue", body = AppResponseData<IssueDto>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn close_issue(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<IssuePath>,
25) -> Result<AppResponseData<IssueDto>, ApiError> {
26 let uid = require_uid(&session)?;
27 let dto = service
28 .close_issue(uid, &path.space_name, path.board_id, path.issue_id, true)
29 .await?;
30 Ok(AppResponseData::ok(dto))
31}
Added lib/issues/src/handlers/issues/create_issue.rs lib/issues/src/handlers/issues/create_issue.rs +35 −0 View lib/issues/src/handlers/issues/create_issue.rs
@@ -0,0 +1,35 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::boards::BoardPath;
7use crate::services::issues::{CreateIssueCmd, IssueDto};
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/issues/{space_name}/{board_id}/issues",
14 tag = "issues",
15 params(BoardPath),
16 request_body = CreateIssueCmd,
17 responses(
18 (status = 200, description = "create issue", body = AppResponseData<IssueDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn create_issue(
25 State(service): State<IssueService>,
26 session: Session,
27 Path(path): Path<BoardPath>,
28 Json(cmd): Json<CreateIssueCmd>,
29) -> Result<AppResponseData<IssueDto>, ApiError> {
30 let uid = require_uid(&session)?;
31 let dto = service
32 .create_issue(uid, &path.space_name, path.board_id, cmd)
33 .await?;
34 Ok(AppResponseData::ok(dto))
35}
Added lib/issues/src/handlers/issues/delete_issue.rs lib/issues/src/handlers/issues/delete_issue.rs +31 −0 View lib/issues/src/handlers/issues/delete_issue.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issues::IssuePath;
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/{space_name}/{board_id}/{issue_id}",
13 tag = "issues",
14 params(IssuePath),
15 responses(
16 (status = 200, description = "delete issue", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_issue(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<IssuePath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_issue(uid, &path.space_name, path.board_id, path.issue_id)
29 .await?;
30 Ok(AppResponseData::ok(NoData))
31}
Added lib/issues/src/handlers/issues/get_issue.rs lib/issues/src/handlers/issues/get_issue.rs +31 −0 View lib/issues/src/handlers/issues/get_issue.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issues::{IssueDto, IssuePath};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/issues/{space_name}/{board_id}/{issue_id}",
13 tag = "issues",
14 params(IssuePath),
15 responses(
16 (status = 200, description = "get issue", body = AppResponseData<IssueDto>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn get_issue(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<IssuePath>,
25) -> Result<AppResponseData<IssueDto>, ApiError> {
26 let uid = require_uid(&session)?;
27 let dto = service
28 .get_issue(uid, &path.space_name, path.board_id, path.issue_id)
29 .await?;
30 Ok(AppResponseData::ok(dto))
31}
Added lib/issues/src/handlers/issues/list_issues.rs lib/issues/src/handlers/issues/list_issues.rs +33 −0 View lib/issues/src/handlers/issues/list_issues.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::boards::BoardPath;
7use crate::services::issues::{IssuePage, ListIssueQuery};
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}/{board_id}/issues",
14 tag = "issues",
15 params(BoardPath, ListIssueQuery),
16 responses(
17 (status = 200, description = "list issues", body = AppResponseData<IssuePage>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_issues(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<BoardPath>,
26 Query(q): Query<ListIssueQuery>,
27) -> Result<AppResponseData<IssuePage>, ApiError> {
28 let uid = require_uid(&session)?;
29 let page = service
30 .list_issues(uid, &path.space_name, path.board_id, q)
31 .await?;
32 Ok(AppResponseData::ok(page))
33}
Added lib/issues/src/handlers/issues/mod.rs lib/issues/src/handlers/issues/mod.rs +7 −0 View lib/issues/src/handlers/issues/mod.rs
@@ -0,0 +1,7 @@
1pub mod close_issue;
2pub mod create_issue;
3pub mod delete_issue;
4pub mod get_issue;
5pub mod list_issues;
6pub mod reopen_issue;
7pub mod update_issue;
Added lib/issues/src/handlers/issues/reopen_issue.rs lib/issues/src/handlers/issues/reopen_issue.rs +31 −0 View lib/issues/src/handlers/issues/reopen_issue.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issues::{IssueDto, IssuePath};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 post,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/reopen",
13 tag = "issues",
14 params(IssuePath),
15 responses(
16 (status = 200, description = "reopen issue", body = AppResponseData<IssueDto>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn reopen_issue(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<IssuePath>,
25) -> Result<AppResponseData<IssueDto>, ApiError> {
26 let uid = require_uid(&session)?;
27 let dto = service
28 .close_issue(uid, &path.space_name, path.board_id, path.issue_id, false)
29 .await?;
30 Ok(AppResponseData::ok(dto))
31}
Added lib/issues/src/handlers/issues/update_issue.rs lib/issues/src/handlers/issues/update_issue.rs +34 −0 View lib/issues/src/handlers/issues/update_issue.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issues::{IssueDto, IssuePath, UpdateIssueCmd};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 patch,
12 path = "/issues/{space_name}/{board_id}/{issue_id}",
13 tag = "issues",
14 params(IssuePath),
15 request_body = UpdateIssueCmd,
16 responses(
17 (status = 200, description = "update issue", body = AppResponseData<IssueDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn update_issue(
24 State(service): State<IssueService>,
25 session: Session,
26 Path(path): Path<IssuePath>,
27 Json(cmd): Json<UpdateIssueCmd>,
28) -> Result<AppResponseData<IssueDto>, ApiError> {
29 let uid = require_uid(&session)?;
30 let dto = service
31 .update_issue(uid, &path.space_name, path.board_id, path.issue_id, cmd)
32 .await?;
33 Ok(AppResponseData::ok(dto))
34}
Added lib/issues/src/handlers/labels/bind_label.rs lib/issues/src/handlers/labels/bind_label.rs +38 −0 View lib/issues/src/handlers/labels/bind_label.rs
@@ -0,0 +1,38 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::labels::IssueLabelPath;
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 post,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/labels/{label_id}",
13 tag = "issues",
14 params(IssueLabelPath),
15 responses(
16 (status = 200, description = "bind label to issue", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>),
19 (status = 409, description = "conflict", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn bind_label(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<IssueLabelPath>,
26) -> Result<AppResponseData<NoData>, ApiError> {
27 let uid = require_uid(&session)?;
28 service
29 .bind_label(
30 uid,
31 &path.space_name,
32 path.board_id,
33 path.issue_id,
34 path.label_id,
35 )
36 .await?;
37 Ok(AppResponseData::ok(NoData))
38}
Added lib/issues/src/handlers/labels/create_label.rs lib/issues/src/handlers/labels/create_label.rs +35 −0 View lib/issues/src/handlers/labels/create_label.rs
@@ -0,0 +1,35 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::boards::BoardPath;
7use crate::services::labels::{CreateLabelCmd, LabelDto};
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/issues/{space_name}/{board_id}/labels",
14 tag = "issues",
15 params(BoardPath),
16 request_body = CreateLabelCmd,
17 responses(
18 (status = 200, description = "create label", body = AppResponseData<LabelDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn create_label(
25 State(service): State<IssueService>,
26 session: Session,
27 Path(path): Path<BoardPath>,
28 Json(cmd): Json<CreateLabelCmd>,
29) -> Result<AppResponseData<LabelDto>, ApiError> {
30 let uid = require_uid(&session)?;
31 let dto = service
32 .create_label(uid, &path.space_name, path.board_id, cmd)
33 .await?;
34 Ok(AppResponseData::ok(dto))
35}
Added lib/issues/src/handlers/labels/delete_label.rs lib/issues/src/handlers/labels/delete_label.rs +31 −0 View lib/issues/src/handlers/labels/delete_label.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::labels::LabelPath;
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/{space_name}/{board_id}/labels/{label_id}",
13 tag = "issues",
14 params(LabelPath),
15 responses(
16 (status = 200, description = "delete label", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_label(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<LabelPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_label(uid, &path.space_name, path.board_id, path.label_id)
29 .await?;
30 Ok(AppResponseData::ok(NoData))
31}
Added lib/issues/src/handlers/labels/get_label.rs lib/issues/src/handlers/labels/get_label.rs +31 −0 View lib/issues/src/handlers/labels/get_label.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::labels::{LabelDto, LabelPath};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/issues/{space_name}/{board_id}/labels/{label_id}",
13 tag = "issues",
14 params(LabelPath),
15 responses(
16 (status = 200, description = "get label", body = AppResponseData<LabelDto>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn get_label(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<LabelPath>,
25) -> Result<AppResponseData<LabelDto>, ApiError> {
26 let uid = require_uid(&session)?;
27 let dto = service
28 .get_label(uid, &path.space_name, path.board_id, path.label_id)
29 .await?;
30 Ok(AppResponseData::ok(dto))
31}
Added lib/issues/src/handlers/labels/list_labels.rs lib/issues/src/handlers/labels/list_labels.rs +33 −0 View lib/issues/src/handlers/labels/list_labels.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::boards::BoardPath;
7use crate::services::labels::{LabelPage, ListLabelQuery};
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}/{board_id}/labels",
14 tag = "issues",
15 params(BoardPath, ListLabelQuery),
16 responses(
17 (status = 200, description = "list labels", body = AppResponseData<LabelPage>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_labels(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<BoardPath>,
26 Query(q): Query<ListLabelQuery>,
27) -> Result<AppResponseData<LabelPage>, ApiError> {
28 let uid = require_uid(&session)?;
29 let page = service
30 .list_labels(uid, &path.space_name, path.board_id, q)
31 .await?;
32 Ok(AppResponseData::ok(page))
33}
Added lib/issues/src/handlers/labels/list_labels_for_issue.rs lib/issues/src/handlers/labels/list_labels_for_issue.rs +32 −0 View lib/issues/src/handlers/labels/list_labels_for_issue.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issues::IssuePath;
7use crate::services::labels::LabelDto;
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}/{board_id}/{issue_id}/labels",
14 tag = "issues",
15 params(IssuePath),
16 responses(
17 (status = 200, description = "list labels for issue", body = AppResponseData<Vec<LabelDto>>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_labels_for_issue(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<IssuePath>,
26) -> Result<AppResponseData<Vec<LabelDto>>, ApiError> {
27 let uid = require_uid(&session)?;
28 let labels = service
29 .list_labels_for_issue(uid, &path.space_name, path.board_id, path.issue_id)
30 .await?;
31 Ok(AppResponseData::ok(labels))
32}
Added lib/issues/src/handlers/labels/mod.rs lib/issues/src/handlers/labels/mod.rs +8 −0 View lib/issues/src/handlers/labels/mod.rs
@@ -0,0 +1,8 @@
1pub mod bind_label;
2pub mod create_label;
3pub mod delete_label;
4pub mod get_label;
5pub mod list_labels;
6pub mod list_labels_for_issue;
7pub mod unbind_label;
8pub mod update_label;
Added lib/issues/src/handlers/labels/unbind_label.rs lib/issues/src/handlers/labels/unbind_label.rs +37 −0 View lib/issues/src/handlers/labels/unbind_label.rs
@@ -0,0 +1,37 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::labels::IssueLabelPath;
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/labels/{label_id}",
13 tag = "issues",
14 params(IssueLabelPath),
15 responses(
16 (status = 200, description = "unbind label from issue", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn unbind_label(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<IssueLabelPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .unbind_label(
29 uid,
30 &path.space_name,
31 path.board_id,
32 path.issue_id,
33 path.label_id,
34 )
35 .await?;
36 Ok(AppResponseData::ok(NoData))
37}
Added lib/issues/src/handlers/labels/update_label.rs lib/issues/src/handlers/labels/update_label.rs +34 −0 View lib/issues/src/handlers/labels/update_label.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::labels::{LabelDto, LabelPath, UpdateLabelCmd};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 patch,
12 path = "/issues/{space_name}/{board_id}/labels/{label_id}",
13 tag = "issues",
14 params(LabelPath),
15 request_body = UpdateLabelCmd,
16 responses(
17 (status = 200, description = "update label", body = AppResponseData<LabelDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn update_label(
24 State(service): State<IssueService>,
25 session: Session,
26 Path(path): Path<LabelPath>,
27 Json(cmd): Json<UpdateLabelCmd>,
28) -> Result<AppResponseData<LabelDto>, ApiError> {
29 let uid = require_uid(&session)?;
30 let dto = service
31 .update_label(uid, &path.space_name, path.board_id, path.label_id, cmd)
32 .await?;
33 Ok(AppResponseData::ok(dto))
34}
Added lib/issues/src/handlers/members/create_member.rs lib/issues/src/handlers/members/create_member.rs +36 −0 View lib/issues/src/handlers/members/create_member.rs
@@ -0,0 +1,36 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issues::IssuePath;
7use crate::services::members::{CreateIssueMemberCmd, IssueMemberDto};
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/issues/{space_name}/{board_id}/{issue_id}/members",
14 tag = "issues",
15 params(IssuePath),
16 request_body = CreateIssueMemberCmd,
17 responses(
18 (status = 200, description = "create issue member", body = AppResponseData<IssueMemberDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>),
22 (status = 409, description = "conflict", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn create_member(
26 State(service): State<IssueService>,
27 session: Session,
28 Path(path): Path<IssuePath>,
29 Json(cmd): Json<CreateIssueMemberCmd>,
30) -> Result<AppResponseData<IssueMemberDto>, ApiError> {
31 let uid = require_uid(&session)?;
32 let dto = service
33 .create_member(uid, &path.space_name, path.board_id, path.issue_id, cmd)
34 .await?;
35 Ok(AppResponseData::ok(dto))
36}
Added lib/issues/src/handlers/members/delete_member.rs lib/issues/src/handlers/members/delete_member.rs +37 −0 View lib/issues/src/handlers/members/delete_member.rs
@@ -0,0 +1,37 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::members::IssueMemberPath;
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/members/{member_id}",
13 tag = "issues",
14 params(IssueMemberPath),
15 responses(
16 (status = 200, description = "delete issue member", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_member(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<IssueMemberPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_member(
29 uid,
30 &path.space_name,
31 path.board_id,
32 path.issue_id,
33 path.member_id,
34 )
35 .await?;
36 Ok(AppResponseData::ok(NoData))
37}
Added lib/issues/src/handlers/members/list_members.rs lib/issues/src/handlers/members/list_members.rs +33 −0 View lib/issues/src/handlers/members/list_members.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issues::IssuePath;
7use crate::services::members::{IssueMemberPage, ListIssueMemberQuery};
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}/{board_id}/{issue_id}/members",
14 tag = "issues",
15 params(IssuePath, ListIssueMemberQuery),
16 responses(
17 (status = 200, description = "list issue members", body = AppResponseData<IssueMemberPage>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_members(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<IssuePath>,
26 Query(q): Query<ListIssueMemberQuery>,
27) -> Result<AppResponseData<IssueMemberPage>, ApiError> {
28 let uid = require_uid(&session)?;
29 let page = service
30 .list_members(uid, &path.space_name, path.board_id, path.issue_id, q)
31 .await?;
32 Ok(AppResponseData::ok(page))
33}
Added lib/issues/src/handlers/members/mod.rs lib/issues/src/handlers/members/mod.rs +3 −0 View lib/issues/src/handlers/members/mod.rs
@@ -0,0 +1,3 @@
1pub mod create_member;
2pub mod delete_member;
3pub mod list_members;
Added lib/issues/src/handlers/milestones/create_milestone.rs lib/issues/src/handlers/milestones/create_milestone.rs +35 −0 View lib/issues/src/handlers/milestones/create_milestone.rs
@@ -0,0 +1,35 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::boards::BoardPath;
7use crate::services::milestones::{CreateMilestoneCmd, MilestoneDto};
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/issues/{space_name}/{board_id}/milestones",
14 tag = "issues",
15 params(BoardPath),
16 request_body = CreateMilestoneCmd,
17 responses(
18 (status = 200, description = "create milestone", body = AppResponseData<MilestoneDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn create_milestone(
25 State(service): State<IssueService>,
26 session: Session,
27 Path(path): Path<BoardPath>,
28 Json(cmd): Json<CreateMilestoneCmd>,
29) -> Result<AppResponseData<MilestoneDto>, ApiError> {
30 let uid = require_uid(&session)?;
31 let dto = service
32 .create_milestone(uid, &path.space_name, path.board_id, cmd)
33 .await?;
34 Ok(AppResponseData::ok(dto))
35}
Added lib/issues/src/handlers/milestones/delete_milestone.rs lib/issues/src/handlers/milestones/delete_milestone.rs +31 −0 View lib/issues/src/handlers/milestones/delete_milestone.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::milestones::MilestonePath;
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/{space_name}/{board_id}/milestones/{milestone_id}",
13 tag = "issues",
14 params(MilestonePath),
15 responses(
16 (status = 200, description = "delete milestone", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_milestone(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<MilestonePath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_milestone(uid, &path.space_name, path.board_id, path.milestone_id)
29 .await?;
30 Ok(AppResponseData::ok(NoData))
31}
Added lib/issues/src/handlers/milestones/get_milestone.rs lib/issues/src/handlers/milestones/get_milestone.rs +31 −0 View lib/issues/src/handlers/milestones/get_milestone.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::milestones::{MilestoneDto, MilestonePath};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/issues/{space_name}/{board_id}/milestones/{milestone_id}",
13 tag = "issues",
14 params(MilestonePath),
15 responses(
16 (status = 200, description = "get milestone", body = AppResponseData<MilestoneDto>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn get_milestone(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<MilestonePath>,
25) -> Result<AppResponseData<MilestoneDto>, ApiError> {
26 let uid = require_uid(&session)?;
27 let dto = service
28 .get_milestone(uid, &path.space_name, path.board_id, path.milestone_id)
29 .await?;
30 Ok(AppResponseData::ok(dto))
31}
Added lib/issues/src/handlers/milestones/list_milestones.rs lib/issues/src/handlers/milestones/list_milestones.rs +33 −0 View lib/issues/src/handlers/milestones/list_milestones.rs
@@ -0,0 +1,33 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::boards::BoardPath;
7use crate::services::milestones::{ListMilestoneQuery, MilestonePage};
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}/{board_id}/milestones",
14 tag = "issues",
15 params(BoardPath, ListMilestoneQuery),
16 responses(
17 (status = 200, description = "list milestones", body = AppResponseData<MilestonePage>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_milestones(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<BoardPath>,
26 Query(q): Query<ListMilestoneQuery>,
27) -> Result<AppResponseData<MilestonePage>, ApiError> {
28 let uid = require_uid(&session)?;
29 let page = service
30 .list_milestones(uid, &path.space_name, path.board_id, q)
31 .await?;
32 Ok(AppResponseData::ok(page))
33}
Added lib/issues/src/handlers/milestones/mod.rs lib/issues/src/handlers/milestones/mod.rs +5 −0 View lib/issues/src/handlers/milestones/mod.rs
@@ -0,0 +1,5 @@
1pub mod create_milestone;
2pub mod delete_milestone;
3pub mod get_milestone;
4pub mod list_milestones;
5pub mod update_milestone;
Added lib/issues/src/handlers/milestones/update_milestone.rs lib/issues/src/handlers/milestones/update_milestone.rs +40 −0 View lib/issues/src/handlers/milestones/update_milestone.rs
@@ -0,0 +1,40 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::milestones::{MilestoneDto, MilestonePath, UpdateMilestoneCmd};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 patch,
12 path = "/issues/{space_name}/{board_id}/milestones/{milestone_id}",
13 tag = "issues",
14 params(MilestonePath),
15 request_body = UpdateMilestoneCmd,
16 responses(
17 (status = 200, description = "update milestone", body = AppResponseData<MilestoneDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn update_milestone(
24 State(service): State<IssueService>,
25 session: Session,
26 Path(path): Path<MilestonePath>,
27 Json(cmd): Json<UpdateMilestoneCmd>,
28) -> Result<AppResponseData<MilestoneDto>, ApiError> {
29 let uid = require_uid(&session)?;
30 let dto = service
31 .update_milestone(
32 uid,
33 &path.space_name,
34 path.board_id,
35 path.milestone_id,
36 cmd,
37 )
38 .await?;
39 Ok(AppResponseData::ok(dto))
40}
Added lib/issues/src/handlers/pins/create_pin.rs lib/issues/src/handlers/pins/create_pin.rs +36 −0 View lib/issues/src/handlers/pins/create_pin.rs
@@ -0,0 +1,36 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::boards::BoardPath;
7use crate::services::pins::{CreatePinCmd, PinDto};
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/issues/{space_name}/{board_id}/pins",
14 tag = "issues",
15 params(BoardPath),
16 request_body = CreatePinCmd,
17 responses(
18 (status = 200, description = "create pin", body = AppResponseData<PinDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>),
22 (status = 409, description = "conflict", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn create_pin(
26 State(service): State<IssueService>,
27 session: Session,
28 Path(path): Path<BoardPath>,
29 Json(cmd): Json<CreatePinCmd>,
30) -> Result<AppResponseData<PinDto>, ApiError> {
31 let uid = require_uid(&session)?;
32 let dto = service
33 .create_pin(uid, &path.space_name, path.board_id, cmd)
34 .await?;
35 Ok(AppResponseData::ok(dto))
36}
Added lib/issues/src/handlers/pins/delete_pin.rs lib/issues/src/handlers/pins/delete_pin.rs +31 −0 View lib/issues/src/handlers/pins/delete_pin.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::pins::PinPath;
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/{space_name}/{board_id}/pins/{pin_id}",
13 tag = "issues",
14 params(PinPath),
15 responses(
16 (status = 200, description = "delete pin", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_pin(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<PinPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_pin(uid, &path.space_name, path.board_id, path.pin_id)
29 .await?;
30 Ok(AppResponseData::ok(NoData))
31}
Added lib/issues/src/handlers/pins/list_pins.rs lib/issues/src/handlers/pins/list_pins.rs +32 −0 View lib/issues/src/handlers/pins/list_pins.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::boards::BoardPath;
7use crate::services::pins::PinDto;
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}/{board_id}/pins",
14 tag = "issues",
15 params(BoardPath),
16 responses(
17 (status = 200, description = "list pinned issues", body = AppResponseData<Vec<PinDto>>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_pins(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<BoardPath>,
26) -> Result<AppResponseData<Vec<PinDto>>, ApiError> {
27 let uid = require_uid(&session)?;
28 let pins = service
29 .list_pins(uid, &path.space_name, path.board_id)
30 .await?;
31 Ok(AppResponseData::ok(pins))
32}
Added lib/issues/src/handlers/pins/mod.rs lib/issues/src/handlers/pins/mod.rs +3 −0 View lib/issues/src/handlers/pins/mod.rs
@@ -0,0 +1,3 @@
1pub mod create_pin;
2pub mod delete_pin;
3pub mod list_pins;
Added lib/issues/src/handlers/reactions/create_comment_reaction.rs lib/issues/src/handlers/reactions/create_comment_reaction.rs +42 −0 View lib/issues/src/handlers/reactions/create_comment_reaction.rs
@@ -0,0 +1,42 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::reactions::{CommentReactionPath, CreateReactionCmd, ReactionDto};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 post,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/comments/{comment_id}/reactions",
13 tag = "issues",
14 params(CommentReactionPath),
15 request_body = CreateReactionCmd,
16 responses(
17 (status = 200, description = "create comment reaction", body = AppResponseData<ReactionDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>),
21 (status = 409, description = "conflict", body = AppResponseData<NoData>)
22 )
23)]
24pub async fn create_comment_reaction(
25 State(service): State<IssueService>,
26 session: Session,
27 Path(path): Path<CommentReactionPath>,
28 Json(cmd): Json<CreateReactionCmd>,
29) -> Result<AppResponseData<ReactionDto>, ApiError> {
30 let uid = require_uid(&session)?;
31 let dto = service
32 .create_comment_reaction(
33 uid,
34 &path.space_name,
35 path.board_id,
36 path.issue_id,
37 path.comment_id,
38 cmd,
39 )
40 .await?;
41 Ok(AppResponseData::ok(dto))
42}
Added lib/issues/src/handlers/reactions/create_reaction.rs lib/issues/src/handlers/reactions/create_reaction.rs +36 −0 View lib/issues/src/handlers/reactions/create_reaction.rs
@@ -0,0 +1,36 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issues::IssuePath;
7use crate::services::reactions::{CreateReactionCmd, ReactionDto};
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/issues/{space_name}/{board_id}/{issue_id}/reactions",
14 tag = "issues",
15 params(IssuePath),
16 request_body = CreateReactionCmd,
17 responses(
18 (status = 200, description = "create reaction", body = AppResponseData<ReactionDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>),
22 (status = 409, description = "conflict", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn create_reaction(
26 State(service): State<IssueService>,
27 session: Session,
28 Path(path): Path<IssuePath>,
29 Json(cmd): Json<CreateReactionCmd>,
30) -> Result<AppResponseData<ReactionDto>, ApiError> {
31 let uid = require_uid(&session)?;
32 let dto = service
33 .create_reaction(uid, &path.space_name, path.board_id, path.issue_id, cmd)
34 .await?;
35 Ok(AppResponseData::ok(dto))
36}
Added lib/issues/src/handlers/reactions/delete_reaction.rs lib/issues/src/handlers/reactions/delete_reaction.rs +37 −0 View lib/issues/src/handlers/reactions/delete_reaction.rs
@@ -0,0 +1,37 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::reactions::ReactionPath;
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/reactions/{reaction_id}",
13 tag = "issues",
14 params(ReactionPath),
15 responses(
16 (status = 200, description = "delete reaction", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_reaction(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<ReactionPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_reaction(
29 uid,
30 &path.space_name,
31 path.board_id,
32 path.issue_id,
33 path.reaction_id,
34 )
35 .await?;
36 Ok(AppResponseData::ok(NoData))
37}
Added lib/issues/src/handlers/reactions/list_comment_reactions.rs lib/issues/src/handlers/reactions/list_comment_reactions.rs +37 −0 View lib/issues/src/handlers/reactions/list_comment_reactions.rs
@@ -0,0 +1,37 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::reactions::{CommentReactionPath, ReactionAggDto};
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/comments/{comment_id}/reactions",
13 tag = "issues",
14 params(CommentReactionPath),
15 responses(
16 (status = 200, description = "list comment reactions", body = AppResponseData<Vec<ReactionAggDto>>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn list_comment_reactions(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<CommentReactionPath>,
25) -> Result<AppResponseData<Vec<ReactionAggDto>>, ApiError> {
26 let uid = require_uid(&session)?;
27 let list = service
28 .list_comment_reactions(
29 uid,
30 &path.space_name,
31 path.board_id,
32 path.issue_id,
33 path.comment_id,
34 )
35 .await?;
36 Ok(AppResponseData::ok(list))
37}
Added lib/issues/src/handlers/reactions/list_reactions.rs lib/issues/src/handlers/reactions/list_reactions.rs +32 −0 View lib/issues/src/handlers/reactions/list_reactions.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issues::IssuePath;
7use crate::services::reactions::ReactionAggDto;
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}/{board_id}/{issue_id}/reactions",
14 tag = "issues",
15 params(IssuePath),
16 responses(
17 (status = 200, description = "list reactions", body = AppResponseData<Vec<ReactionAggDto>>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_reactions(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<IssuePath>,
26) -> Result<AppResponseData<Vec<ReactionAggDto>>, ApiError> {
27 let uid = require_uid(&session)?;
28 let list = service
29 .list_reactions(uid, &path.space_name, path.board_id, path.issue_id)
30 .await?;
31 Ok(AppResponseData::ok(list))
32}
Added lib/issues/src/handlers/reactions/mod.rs lib/issues/src/handlers/reactions/mod.rs +5 −0 View lib/issues/src/handlers/reactions/mod.rs
@@ -0,0 +1,5 @@
1pub mod create_comment_reaction;
2pub mod create_reaction;
3pub mod delete_reaction;
4pub mod list_comment_reactions;
5pub mod list_reactions;
Added lib/issues/src/handlers/repo_relations/create_repo_relation.rs lib/issues/src/handlers/repo_relations/create_repo_relation.rs +36 −0 View lib/issues/src/handlers/repo_relations/create_repo_relation.rs
@@ -0,0 +1,36 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issues::IssuePath;
7use crate::services::repo_relations::{CreateRepoRelCmd, RepoRelDto};
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 post,
13 path = "/issues/{space_name}/{board_id}/{issue_id}/repos",
14 tag = "issues",
15 params(IssuePath),
16 request_body = CreateRepoRelCmd,
17 responses(
18 (status = 200, description = "create issue repository relation", body = AppResponseData<RepoRelDto>),
19 (status = 400, description = "bad request", body = AppResponseData<NoData>),
20 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
21 (status = 404, description = "not found", body = AppResponseData<NoData>),
22 (status = 409, description = "conflict", body = AppResponseData<NoData>)
23 )
24)]
25pub async fn create_repo_relation(
26 State(service): State<IssueService>,
27 session: Session,
28 Path(path): Path<IssuePath>,
29 Json(cmd): Json<CreateRepoRelCmd>,
30) -> Result<AppResponseData<RepoRelDto>, ApiError> {
31 let uid = require_uid(&session)?;
32 let dto = service
33 .create_repo_relation(uid, &path.space_name, path.board_id, path.issue_id, cmd)
34 .await?;
35 Ok(AppResponseData::ok(dto))
36}
Added lib/issues/src/handlers/repo_relations/delete_repo_relation.rs lib/issues/src/handlers/repo_relations/delete_repo_relation.rs +37 −0 View lib/issues/src/handlers/repo_relations/delete_repo_relation.rs
@@ -0,0 +1,37 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::repo_relations::RepoRelPath;
7use crate::services::service::IssueService;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/repos/{rel_id}",
13 tag = "issues",
14 params(RepoRelPath),
15 responses(
16 (status = 200, description = "delete issue repository relation", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_repo_relation(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<RepoRelPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_repo_relation(
29 uid,
30 &path.space_name,
31 path.board_id,
32 path.issue_id,
33 path.rel_id,
34 )
35 .await?;
36 Ok(AppResponseData::ok(NoData))
37}
Added lib/issues/src/handlers/repo_relations/list_repo_relations.rs lib/issues/src/handlers/repo_relations/list_repo_relations.rs +32 −0 View lib/issues/src/handlers/repo_relations/list_repo_relations.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::issues::IssuePath;
7use crate::services::repo_relations::RepoRelDto;
8use crate::services::service::IssueService;
9use crate::share::session::require_uid;
10
11#[utoipa::path(
12 get,
13 path = "/issues/{space_name}/{board_id}/{issue_id}/repos",
14 tag = "issues",
15 params(IssuePath),
16 responses(
17 (status = 200, description = "list issue repository relations", body = AppResponseData<Vec<RepoRelDto>>),
18 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
19 (status = 404, description = "not found", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn list_repo_relations(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<IssuePath>,
26) -> Result<AppResponseData<Vec<RepoRelDto>>, ApiError> {
27 let uid = require_uid(&session)?;
28 let list = service
29 .list_repo_relations(uid, &path.space_name, path.board_id, path.issue_id)
30 .await?;
31 Ok(AppResponseData::ok(list))
32}
Added lib/issues/src/handlers/repo_relations/mod.rs lib/issues/src/handlers/repo_relations/mod.rs +3 −0 View lib/issues/src/handlers/repo_relations/mod.rs
@@ -0,0 +1,3 @@
1pub mod create_repo_relation;
2pub mod delete_repo_relation;
3pub mod list_repo_relations;
Added lib/issues/src/handlers/stopwatch/get_stopwatch.rs lib/issues/src/handlers/stopwatch/get_stopwatch.rs +31 −0 View lib/issues/src/handlers/stopwatch/get_stopwatch.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::IssueService;
7use crate::services::stopwatch::{StopwatchDto, StopwatchPath};
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/stopwatch",
13 tag = "issues",
14 params(StopwatchPath),
15 responses(
16 (status = 200, description = "get stopwatch status", body = AppResponseData<StopwatchDto>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn get_stopwatch(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<StopwatchPath>,
25) -> Result<AppResponseData<StopwatchDto>, ApiError> {
26 let uid = require_uid(&session)?;
27 let dto = service
28 .get_stopwatch(uid, &path.space_name, path.board_id, path.issue_id)
29 .await?;
30 Ok(AppResponseData::ok(dto))
31}
Added lib/issues/src/handlers/stopwatch/mod.rs lib/issues/src/handlers/stopwatch/mod.rs +3 −0 View lib/issues/src/handlers/stopwatch/mod.rs
@@ -0,0 +1,3 @@
1pub mod get_stopwatch;
2pub mod start_stopwatch;
3pub mod stop_stopwatch;
Added lib/issues/src/handlers/stopwatch/start_stopwatch.rs lib/issues/src/handlers/stopwatch/start_stopwatch.rs +32 −0 View lib/issues/src/handlers/stopwatch/start_stopwatch.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::IssueService;
7use crate::services::stopwatch::{StopwatchDto, StopwatchPath};
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 post,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/stopwatch/start",
13 tag = "issues",
14 params(StopwatchPath),
15 responses(
16 (status = 200, description = "start stopwatch", body = AppResponseData<StopwatchDto>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>),
19 (status = 409, description = "conflict", body = AppResponseData<NoData>)
20 )
21)]
22pub async fn start_stopwatch(
23 State(service): State<IssueService>,
24 session: Session,
25 Path(path): Path<StopwatchPath>,
26) -> Result<AppResponseData<StopwatchDto>, ApiError> {
27 let uid = require_uid(&session)?;
28 let dto = service
29 .start_stopwatch(uid, &path.space_name, path.board_id, path.issue_id)
30 .await?;
31 Ok(AppResponseData::ok(dto))
32}
Added lib/issues/src/handlers/stopwatch/stop_stopwatch.rs lib/issues/src/handlers/stopwatch/stop_stopwatch.rs +31 −0 View lib/issues/src/handlers/stopwatch/stop_stopwatch.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::IssueService;
7use crate::services::stopwatch::StopwatchPath;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 post,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/stopwatch/stop",
13 tag = "issues",
14 params(StopwatchPath),
15 responses(
16 (status = 200, description = "stop stopwatch", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn stop_stopwatch(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<StopwatchPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .stop_stopwatch(uid, &path.space_name, path.board_id, path.issue_id)
29 .await?;
30 Ok(AppResponseData::ok(NoData))
31}
Added lib/issues/src/handlers/tracked_time/list_tracked_time.rs lib/issues/src/handlers/tracked_time/list_tracked_time.rs +32 −0 View lib/issues/src/handlers/tracked_time/list_tracked_time.rs
@@ -0,0 +1,32 @@
1use axum::extract::{Path, Query, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::IssueService;
7use crate::services::tracked_time::{ListTrackedTimeQuery, TrackedTimePage, TrackedTimePath};
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/times",
13 tag = "issues",
14 params(TrackedTimePath, ListTrackedTimeQuery),
15 responses(
16 (status = 200, description = "list tracked times", body = AppResponseData<TrackedTimePage>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn list_tracked_time(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<TrackedTimePath>,
25 Query(q): Query<ListTrackedTimeQuery>,
26) -> Result<AppResponseData<TrackedTimePage>, ApiError> {
27 let uid = require_uid(&session)?;
28 let page = service
29 .list_tracked_time(uid, &path.space_name, path.board_id, path.issue_id, q)
30 .await?;
31 Ok(AppResponseData::ok(page))
32}
Added lib/issues/src/handlers/tracked_time/mod.rs lib/issues/src/handlers/tracked_time/mod.rs +1 −0 View lib/issues/src/handlers/tracked_time/mod.rs
@@ -0,0 +1 @@
1pub mod list_tracked_time;
Added lib/issues/src/handlers/watches/delete_watch.rs lib/issues/src/handlers/watches/delete_watch.rs +31 −0 View lib/issues/src/handlers/watches/delete_watch.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::IssueService;
7use crate::services::watches::WatchPath;
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 delete,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/watch",
13 tag = "issues",
14 params(WatchPath),
15 responses(
16 (status = 200, description = "delete watch", body = AppResponseData<NoData>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn delete_watch(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<WatchPath>,
25) -> Result<AppResponseData<NoData>, ApiError> {
26 let uid = require_uid(&session)?;
27 service
28 .delete_watch(uid, &path.space_name, path.board_id, path.issue_id)
29 .await?;
30 Ok(AppResponseData::ok(NoData))
31}
Added lib/issues/src/handlers/watches/get_watch.rs lib/issues/src/handlers/watches/get_watch.rs +31 −0 View lib/issues/src/handlers/watches/get_watch.rs
@@ -0,0 +1,31 @@
1use axum::extract::{Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::IssueService;
7use crate::services::watches::{WatchDto, WatchPath};
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 get,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/watch",
13 tag = "issues",
14 params(WatchPath),
15 responses(
16 (status = 200, description = "get watch status", body = AppResponseData<WatchDto>),
17 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
18 (status = 404, description = "not found", body = AppResponseData<NoData>)
19 )
20)]
21pub async fn get_watch(
22 State(service): State<IssueService>,
23 session: Session,
24 Path(path): Path<WatchPath>,
25) -> Result<AppResponseData<WatchDto>, ApiError> {
26 let uid = require_uid(&session)?;
27 let dto = service
28 .get_watch(uid, &path.space_name, path.board_id, path.issue_id)
29 .await?;
30 Ok(AppResponseData::ok(dto))
31}
Added lib/issues/src/handlers/watches/mod.rs lib/issues/src/handlers/watches/mod.rs +3 −0 View lib/issues/src/handlers/watches/mod.rs
@@ -0,0 +1,3 @@
1pub mod delete_watch;
2pub mod get_watch;
3pub mod upsert_watch;
Added lib/issues/src/handlers/watches/upsert_watch.rs lib/issues/src/handlers/watches/upsert_watch.rs +34 −0 View lib/issues/src/handlers/watches/upsert_watch.rs
@@ -0,0 +1,34 @@
1use axum::extract::{Json, Path, State};
2use export::rest::{AppResponseData, NoData};
3use session::Session;
4
5use crate::errors::ApiError;
6use crate::services::service::IssueService;
7use crate::services::watches::{UpsertWatchCmd, WatchDto, WatchPath};
8use crate::share::session::require_uid;
9
10#[utoipa::path(
11 put,
12 path = "/issues/{space_name}/{board_id}/{issue_id}/watch",
13 tag = "issues",
14 params(WatchPath),
15 request_body = UpsertWatchCmd,
16 responses(
17 (status = 200, description = "upsert watch", body = AppResponseData<WatchDto>),
18 (status = 400, description = "bad request", body = AppResponseData<NoData>),
19 (status = 403, description = "forbidden", body = AppResponseData<NoData>),
20 (status = 404, description = "not found", body = AppResponseData<NoData>)
21 )
22)]
23pub async fn upsert_watch(
24 State(service): State<IssueService>,
25 session: Session,
26 Path(path): Path<WatchPath>,
27 Json(cmd): Json<UpsertWatchCmd>,
28) -> Result<AppResponseData<WatchDto>, ApiError> {
29 let uid = require_uid(&session)?;
30 let dto = service
31 .upsert_watch(uid, &path.space_name, path.board_id, path.issue_id, cmd)
32 .await?;
33 Ok(AppResponseData::ok(dto))
34}
Added lib/issues/src/handlers/mod.rs lib/issues/src/handlers/mod.rs +16 −0 View lib/issues/src/handlers/mod.rs
@@ -0,0 +1,16 @@
1pub mod boards;
2pub mod comments;
3pub mod content_history;
4pub mod dependencies;
5pub mod events;
6pub mod issue_users;
7pub mod issues;
8pub mod labels;
9pub mod members;
10pub mod milestones;
11pub mod pins;
12pub mod reactions;
13pub mod repo_relations;
14pub mod stopwatch;
15pub mod tracked_time;
16pub mod watches;
Added lib/issues/src/services/boards.rs lib/issues/src/services/boards.rs +306 −0 View lib/issues/src/services/boards.rs
@@ -0,0 +1,306 @@
1use models::dir::dirs::{self, DirKind};
2use models::issue::issue_item;
3use models::issue::issue_pin;
4use models::issue::issues;
5use models::issue::milestone;
6use sea_orm::{
7 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
8 QuerySelect, Set, TransactionTrait,
9};
10use serde::{Deserialize, Serialize};
11use time::OffsetDateTime;
12use utoipa::{IntoParams, ToSchema};
13use uuid::Uuid;
14
15use crate::errors::IssueError;
16use crate::services::issues::delete_issue_cascade_in_txn;
17use crate::services::service::IssueService;
18use crate::share::authz::require_board_manage;
19use crate::share::lookup::{lookup_board, lookup_space_by_name, require_member};
20
21#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
22pub struct BoardDto {
23 pub id: Uuid,
24 pub dir_id: Uuid,
25 pub name: String,
26 pub icon: Option<String>,
27 pub description: Option<String>,
28 pub nums_open_issue: i64,
29 pub nums_closed_issue: i64,
30 pub created_at: OffsetDateTime,
31 pub updated_at: Option<OffsetDateTime>,
32}
33
34#[derive(Debug, Clone, Serialize, ToSchema)]
35pub struct BoardPage {
36 pub items: Vec<BoardDto>,
37 pub total: i64,
38}
39
40#[derive(Debug, Clone, Deserialize, ToSchema)]
41pub struct CreateBoardCmd {
42 pub dir_id: Uuid,
43 pub name: String,
44 #[serde(default)]
45 pub icon: Option<String>,
46 #[serde(default)]
47 pub description: Option<String>,
48}
49
50#[derive(Debug, Clone, Deserialize, ToSchema)]
51pub struct UpdateBoardCmd {
52 #[serde(default)]
53 pub name: Option<String>,
54 #[serde(default)]
55 pub icon: Option<Option<String>>,
56 #[serde(default)]
57 pub description: Option<Option<String>>,
58}
59
60#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
61pub struct ListBoardQuery {
62 pub dir_id: Option<Uuid>,
63 pub q: Option<String>,
64 pub limit: Option<u64>,
65 pub offset: Option<u64>,
66}
67
68#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
69pub struct BoardPath {
70 pub space_name: String,
71 pub board_id: Uuid,
72}
73
74impl IssueService {
75 async fn build_board_dto(&self, board: issues::Model) -> Result<BoardDto, IssueError> {
76 let open_count = issue_item::Entity::find()
77 .filter(issue_item::Column::CollectionId.eq(board.id))
78 .filter(issue_item::Column::ClosedAt.is_null())
79 .count(self.db().read())
80 .await? as i64;
81
82 let closed_count = issue_item::Entity::find()
83 .filter(issue_item::Column::CollectionId.eq(board.id))
84 .filter(issue_item::Column::ClosedAt.is_not_null())
85 .count(self.db().read())
86 .await? as i64;
87
88 Ok(BoardDto {
89 id: board.id,
90 dir_id: board.dir_id,
91 name: board.name,
92 icon: board.icon,
93 description: board.description,
94 nums_open_issue: open_count,
95 nums_closed_issue: closed_count,
96 created_at: board.created_at,
97 updated_at: board.updated_at,
98 })
99 }
100
101 pub async fn create_board(
102 &self,
103 caller: Uuid,
104 space_name: &str,
105 cmd: CreateBoardCmd,
106 ) -> Result<BoardDto, IssueError> {
107 let space = lookup_space_by_name(self, space_name).await?;
108 require_member(self, &space, caller).await?;
109 require_board_manage(self, &space, caller).await?;
110
111 let name = cmd.name.trim();
112 if name.is_empty() || name.chars().count() > self.config().issues.max_board_name_len {
113 return Err(IssueError::BadRequest("board name is empty or too long".to_string()));
114 }
115
116 let dir = dirs::Entity::find_by_id(cmd.dir_id)
117 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
118 .one(self.db().read())
119 .await?
120 .ok_or(IssueError::NotFound)?;
121
122 if dir.kind != DirKind::Dir {
123 return Err(IssueError::BadRequest("target dir must be a dir node".to_string()));
124 }
125
126 let now = OffsetDateTime::now_utc();
127 let row = issues::ActiveModel {
128 id: Set(Uuid::now_v7()),
129 dir_id: Set(cmd.dir_id),
130 name: Set(name.to_string()),
131 icon: Set(cmd.icon),
132 description: Set(cmd.description),
133 created_at: Set(now),
134 updated_at: Set(None),
135 };
136
137 let inserted = row.insert(self.db().write()).await?;
138 Ok(BoardDto {
139 id: inserted.id,
140 dir_id: inserted.dir_id,
141 name: inserted.name,
142 icon: inserted.icon,
143 description: inserted.description,
144 nums_open_issue: 0,
145 nums_closed_issue: 0,
146 created_at: inserted.created_at,
147 updated_at: inserted.updated_at,
148 })
149 }
150
151 pub async fn list_boards(
152 &self,
153 caller: Uuid,
154 space_name: &str,
155 q: ListBoardQuery,
156 ) -> Result<BoardPage, IssueError> {
157 let space = lookup_space_by_name(self, space_name).await?;
158 require_member(self, &space, caller).await?;
159
160 let max_pagination = self.config().issues.max_pagination;
161 let default_pagination = self.config().issues.default_pagination;
162 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
163 let offset = q.offset.unwrap_or(0);
164
165 let dirs_in_space = dirs::Entity::find()
166 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
167 .all(self.db().read())
168 .await?;
169
170 if dirs_in_space.is_empty() {
171 return Ok(BoardPage {
172 items: Vec::new(),
173 total: 0,
174 });
175 }
176
177 let dir_ids: Vec<Uuid> = dirs_in_space.into_iter().map(|d| d.id).collect();
178
179 if q.dir_id.is_some_and(|target_dir| !dir_ids.contains(&target_dir)) {
180 return Ok(BoardPage {
181 items: Vec::new(),
182 total: 0,
183 });
184 }
185
186 let mut select = issues::Entity::find();
187 if let Some(target_dir) = q.dir_id {
188 select = select.filter(issues::Column::DirId.eq(target_dir));
189 } else {
190 select = select.filter(issues::Column::DirId.is_in(dir_ids));
191 }
192
193 if let Some(ref search) = q.q {
194 let kw = search.trim();
195 if !kw.is_empty() {
196 select = select.filter(issues::Column::Name.contains(kw));
197 }
198 }
199
200 select = select.order_by_desc(issues::Column::CreatedAt);
201
202 let total = select.clone().count(self.db().read()).await? as i64;
203 let rows = select
204 .limit(limit)
205 .offset(offset)
206 .all(self.db().read())
207 .await?;
208
209 let mut items = Vec::with_capacity(rows.len());
210 for board in rows {
211 items.push(self.build_board_dto(board).await?);
212 }
213
214 Ok(BoardPage { items, total })
215 }
216
217 pub async fn get_board(
218 &self,
219 caller: Uuid,
220 space_name: &str,
221 board_id: Uuid,
222 ) -> Result<BoardDto, IssueError> {
223 let space = lookup_space_by_name(self, space_name).await?;
224 require_member(self, &space, caller).await?;
225 let board = lookup_board(self, &space, board_id).await?;
226 self.build_board_dto(board).await
227 }
228
229 pub async fn update_board(
230 &self,
231 caller: Uuid,
232 space_name: &str,
233 board_id: Uuid,
234 cmd: UpdateBoardCmd,
235 ) -> Result<BoardDto, IssueError> {
236 let space = lookup_space_by_name(self, space_name).await?;
237 require_member(self, &space, caller).await?;
238 require_board_manage(self, &space, caller).await?;
239 let board = lookup_board(self, &space, board_id).await?;
240
241 let mut am: issues::ActiveModel = board.into();
242 if let Some(new_name) = cmd.name {
243 let name = new_name.trim();
244 if name.is_empty() || name.chars().count() > self.config().issues.max_board_name_len {
245 return Err(IssueError::BadRequest("board name is empty or too long".to_string()));
246 }
247 am.name = Set(name.to_string());
248 }
249 if let Some(icon) = cmd.icon {
250 am.icon = Set(icon);
251 }
252 if let Some(desc) = cmd.description {
253 am.description = Set(desc);
254 }
255
256 let now = OffsetDateTime::now_utc();
257 am.updated_at = Set(Some(now));
258 let updated = am.update(self.db().write()).await?;
259
260 self.build_board_dto(updated).await
261 }
262
263 pub async fn delete_board(
264 &self,
265 caller: Uuid,
266 space_name: &str,
267 board_id: Uuid,
268 ) -> Result<(), IssueError> {
269 let space = lookup_space_by_name(self, space_name).await?;
270 require_member(self, &space, caller).await?;
271 require_board_manage(self, &space, caller).await?;
272 let board = lookup_board(self, &space, board_id).await?;
273
274 self.db()
275 .primary()
276 .transaction::<_, (), IssueError>(|txn| {
277 Box::pin(async move {
278 let issue_rows = issue_item::Entity::find()
279 .filter(issue_item::Column::CollectionId.eq(board.id))
280 .all(txn)
281 .await?;
282
283 issue_pin::Entity::delete_many()
284 .filter(issue_pin::Column::CollectionId.eq(board.id))
285 .exec(txn)
286 .await?;
287
288 for item in issue_rows {
289 delete_issue_cascade_in_txn(txn, item.id).await?;
290 }
291
292 milestone::Entity::delete_many()
293 .filter(milestone::Column::CollectionId.eq(board.id))
294 .exec(txn)
295 .await?;
296
297 issues::Entity::delete_by_id(board.id).exec(txn).await?;
298
299 Ok(())
300 })
301 })
302 .await?;
303
304 Ok(())
305 }
306}
Added lib/issues/src/services/comments.rs lib/issues/src/services/comments.rs +477 −0 View lib/issues/src/services/comments.rs
@@ -0,0 +1,477 @@
1use models::issue::comment;
2use models::issue::issue_member::{self, IssueMemberKind};
3use models::users::user_notifications::NotificationType;
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
6 QuerySelect, Set, TransactionTrait,
7};
8use serde::{Deserialize, Serialize};
9use space::services::spaces::UserBriefDto;
10use time::OffsetDateTime;
11use utoipa::{IntoParams, ToSchema};
12use uuid::Uuid;
13
14use crate::errors::IssueError;
15use crate::services::service::IssueService;
16use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
17
18#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
19pub struct CommentDto {
20 pub id: Uuid,
21 pub issue_id: Option<Uuid>,
22 pub parent_id: Option<Uuid>,
23 pub poster: Option<UserBriefDto>,
24 pub content: String,
25 pub created_at: OffsetDateTime,
26 pub updated_at: Option<OffsetDateTime>,
27}
28
29#[derive(Debug, Clone, Serialize, ToSchema)]
30pub struct CommentPage {
31 pub items: Vec<CommentDto>,
32 pub total: i64,
33}
34
35#[derive(Debug, Clone, Deserialize, ToSchema)]
36pub struct CreateCommentCmd {
37 pub content: String,
38 #[serde(default)]
39 pub parent_id: Option<Uuid>,
40}
41
42#[derive(Debug, Clone, Deserialize, ToSchema)]
43pub struct UpdateCommentCmd {
44 pub content: String,
45}
46
47#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
48pub struct ListCommentQuery {
49 pub parent_id: Option<Uuid>,
50 pub limit: Option<u64>,
51 pub offset: Option<u64>,
52}
53
54#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
55pub struct CommentPath {
56 pub space_name: String,
57 pub board_id: Uuid,
58 pub issue_id: Uuid,
59 pub comment_id: Uuid,
60}
61
62impl IssueService {
63 pub async fn create_comment(
64 &self,
65 caller: Uuid,
66 space_name: &str,
67 board_id: Uuid,
68 issue_id: Uuid,
69 cmd: CreateCommentCmd,
70 ) -> Result<CommentDto, IssueError> {
71 let space = lookup_space_by_name(self, space_name).await?;
72 require_member(self, &space, caller).await?;
73 let board = lookup_board(self, &space, board_id).await?;
74 let item = lookup_issue(self, &board, issue_id).await?;
75
76 let content = cmd.content.trim();
77 if content.is_empty() || content.chars().count() > self.config().issues.max_comment_len {
78 return Err(IssueError::BadRequest("comment content is empty or too long".to_string()));
79 }
80
81 let mut parent_poster_id: Option<Uuid> = None;
82 if let Some(pid) = cmd.parent_id {
83 let parent_comment = comment::Entity::find_by_id(pid)
84 .one(self.db().read())
85 .await?
86 .ok_or_else(|| IssueError::BadRequest("parent comment not found".to_string()))?;
87
88 if parent_comment.issue_id != Some(issue_id) {
89 return Err(IssueError::BadRequest(
90 "parent comment must belong to the same issue".to_string(),
91 ));
92 }
93 if parent_comment.poster_id != caller {
94 parent_poster_id = Some(parent_comment.poster_id);
95 }
96 }
97
98 let mentioned_uids = self
99 .collect_mentioned_uids(&space, content, caller)
100 .await?;
101
102 let now = OffsetDateTime::now_utc();
103 let comment_id = Uuid::now_v7();
104 let content_str = content.to_string();
105 let parent_opt = cmd.parent_id;
106 let this = self.clone();
107 let space_lower_for_txn = space.lower_name.clone();
108 let item_title_for_txn = item.title.clone();
109 let mentioned_for_txn = mentioned_uids.clone();
110
111 let inserted = self
112 .db()
113 .primary()
114 .transaction::<_, comment::Model, IssueError>(move |txn| {
115 let content_clone = content_str.clone();
116 let mentioned_clone = mentioned_for_txn;
117 let space_name_clone = space_lower_for_txn;
118 let issue_num = item.autoincrement_id;
119 let issue_title_clone = item_title_for_txn;
120 Box::pin(async move {
121 let row = comment::ActiveModel {
122 id: Set(comment_id),
123 issue_id: Set(Some(issue_id)),
124 project_id: Set(None),
125 parent_id: Set(parent_opt),
126 poster_id: Set(caller),
127 content: Set(content_clone.clone()),
128 created_at: Set(now),
129 updated_at: Set(None),
130 };
131 let ins = row.insert(txn).await?;
132
133 this.write_event(
134 txn,
135 issue_id,
136 caller,
137 "comment_created",
138 Some(comment_id),
139 None,
140 None,
141 None,
142 )
143 .await?;
144
145 if let Some(pp_uid) = parent_poster_id {
146 let data = serde_json::json!({
147 "space_name": space_name_clone,
148 "board_id": board.id,
149 "issue_id": issue_id,
150 "comment_id": comment_id,
151 "autoincrement_id": issue_num,
152 });
153 this.notify_user(
154 txn,
155 pp_uid,
156 NotificationType::Reply,
157 format!("New reply on issue #{issue_num}: {issue_title_clone}"),
158 Some(content_clone.clone()),
159 Some(data),
160 Some(caller),
161 )
162 .await?;
163 }
164
165 for m_uid in mentioned_clone {
166 if Some(m_uid) == parent_poster_id {
167 continue;
168 }
169
170 let existing_member = issue_member::Entity::find()
171 .filter(issue_member::Column::IssueId.eq(issue_id))
172 .filter(issue_member::Column::UserId.eq(m_uid))
173 .filter(issue_member::Column::Kind.eq(IssueMemberKind::Mentioned))
174 .one(txn)
175 .await?;
176 if existing_member.is_none() {
177 let m_row = issue_member::ActiveModel {
178 id: Set(Uuid::now_v7()),
179 issue_id: Set(issue_id),
180 user_id: Set(m_uid),
181 kind: Set(IssueMemberKind::Mentioned),
182 created_at: Set(now),
183 updated_at: Set(None),
184 };
185 m_row.insert(txn).await?;
186 }
187
188 let data = serde_json::json!({
189 "space_name": space_name_clone,
190 "board_id": board.id,
191 "issue_id": issue_id,
192 "comment_id": comment_id,
193 "autoincrement_id": issue_num,
194 });
195 this.notify_user(
196 txn,
197 m_uid,
198 NotificationType::Mention,
199 format!("You were mentioned in issue #{issue_num}: {issue_title_clone}"),
200 Some(content_clone.clone()),
201 Some(data),
202 Some(caller),
203 )
204 .await?;
205 }
206
207 Ok(ins)
208 })
209 })
210 .await?;
211
212 let mut email_recipients = Vec::new();
213 if let Some(pp_uid) = parent_poster_id {
214 email_recipients.push(pp_uid);
215 }
216 for m_uid in &mentioned_uids {
217 if !email_recipients.contains(m_uid) {
218 email_recipients.push(*m_uid);
219 }
220 }
221
222 if !email_recipients.is_empty() {
223 let emails = self.primary_emails(&email_recipients).await?;
224 for (uid, addr) in emails {
225 let is_reply = Some(uid) == parent_poster_id;
226 let subject = if is_reply {
227 format!(
228 "New reply on issue #{}: {}",
229 item.autoincrement_id, item.title
230 )
231 } else {
232 format!(
233 "You were mentioned in issue #{}: {}",
234 item.autoincrement_id, item.title
235 )
236 };
237 let link = format!(
238 "{}/issues/{}/{}/{}",
239 self.config().web.web_base_url,
240 space.lower_name,
241 board.id,
242 item.id
243 );
244 let text = format!("{}\n\nView issue: {}", subject, link);
245 let html = format!(
246 r#"<p>{}</p><p><a href="{}">View Issue</a></p>"#,
247 subject, link
248 );
249 if let Err(err) = self.send_email(&addr, &subject, &text, &html).await {
250 tracing::warn!(error = %err, "failed to send comment notification email");
251 }
252 }
253 }
254
255 let user_map = self.load_user_briefs(&[inserted.poster_id]).await?;
256 let poster = user_map.get(&inserted.poster_id).cloned();
257
258 Ok(CommentDto {
259 id: inserted.id,
260 issue_id: inserted.issue_id,
261 parent_id: inserted.parent_id,
262 poster,
263 content: inserted.content,
264 created_at: inserted.created_at,
265 updated_at: inserted.updated_at,
266 })
267 }
268
269 pub async fn list_comments(
270 &self,
271 caller: Uuid,
272 space_name: &str,
273 board_id: Uuid,
274 issue_id: Uuid,
275 q: ListCommentQuery,
276 ) -> Result<CommentPage, IssueError> {
277 let space = lookup_space_by_name(self, space_name).await?;
278 require_member(self, &space, caller).await?;
279 let board = lookup_board(self, &space, board_id).await?;
280 let _ = lookup_issue(self, &board, issue_id).await?;
281
282 let max_pagination = self.config().issues.max_pagination;
283 let default_pagination = self.config().issues.default_pagination;
284 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
285 let offset = q.offset.unwrap_or(0);
286
287 let mut select = comment::Entity::find()
288 .filter(comment::Column::IssueId.eq(Some(issue_id)));
289
290 if let Some(pid) = q.parent_id {
291 select = select.filter(comment::Column::ParentId.eq(Some(pid)));
292 }
293
294 select = select.order_by_asc(comment::Column::CreatedAt);
295
296 let total = select.clone().count(self.db().read()).await? as i64;
297 let rows = select
298 .limit(limit)
299 .offset(offset)
300 .all(self.db().read())
301 .await?;
302
303 let poster_ids: Vec<Uuid> = rows.iter().map(|c| c.poster_id).collect();
304 let user_map = self.load_user_briefs(&poster_ids).await?;
305
306 let items = rows
307 .into_iter()
308 .map(|c| {
309 let poster = user_map.get(&c.poster_id).cloned();
310 CommentDto {
311 id: c.id,
312 issue_id: c.issue_id,
313 parent_id: c.parent_id,
314 poster,
315 content: c.content,
316 created_at: c.created_at,
317 updated_at: c.updated_at,
318 }
319 })
320 .collect();
321
322 Ok(CommentPage { items, total })
323 }
324
325 pub async fn get_comment(
326 &self,
327 caller: Uuid,
328 space_name: &str,
329 board_id: Uuid,
330 issue_id: Uuid,
331 comment_id: Uuid,
332 ) -> Result<CommentDto, IssueError> {
333 let space = lookup_space_by_name(self, space_name).await?;
334 require_member(self, &space, caller).await?;
335 let board = lookup_board(self, &space, board_id).await?;
336 let _ = lookup_issue(self, &board, issue_id).await?;
337
338 let c = comment::Entity::find_by_id(comment_id)
339 .filter(comment::Column::IssueId.eq(Some(issue_id)))
340 .one(self.db().read())
341 .await?
342 .ok_or(IssueError::NotFound)?;
343
344 let user_map = self.load_user_briefs(&[c.poster_id]).await?;
345 let poster = user_map.get(&c.poster_id).cloned();
346
347 Ok(CommentDto {
348 id: c.id,
349 issue_id: c.issue_id,
350 parent_id: c.parent_id,
351 poster,
352 content: c.content,
353 created_at: c.created_at,
354 updated_at: c.updated_at,
355 })
356 }
357
358 pub async fn update_comment(
359 &self,
360 caller: Uuid,
361 space_name: &str,
362 board_id: Uuid,
363 issue_id: Uuid,
364 comment_id: Uuid,
365 cmd: UpdateCommentCmd,
366 ) -> Result<CommentDto, IssueError> {
367 let space = lookup_space_by_name(self, space_name).await?;
368 require_member(self, &space, caller).await?;
369 let board = lookup_board(self, &space, board_id).await?;
370 let _ = lookup_issue(self, &board, issue_id).await?;
371
372 let c = comment::Entity::find_by_id(comment_id)
373 .filter(comment::Column::IssueId.eq(Some(issue_id)))
374 .one(self.db().read())
375 .await?
376 .ok_or(IssueError::NotFound)?;
377
378 if c.poster_id != caller {
379 return Err(IssueError::Forbidden);
380 }
381
382 let content = cmd.content.trim();
383 if content.is_empty() || content.chars().count() > self.config().issues.max_comment_len {
384 return Err(IssueError::BadRequest("comment content is empty or too long".to_string()));
385 }
386
387 let now = OffsetDateTime::now_utc();
388 let mut am: comment::ActiveModel = c.into();
389 am.content = Set(content.to_string());
390 am.updated_at = Set(Some(now));
391 let this = self.clone();
392
393 let updated = self
394 .db()
395 .primary()
396 .transaction::<_, comment::Model, IssueError>(move |txn| {
397 Box::pin(async move {
398 let u = am.update(txn).await?;
399 this.write_event(
400 txn,
401 issue_id,
402 caller,
403 "comment_updated",
404 Some(comment_id),
405 None,
406 None,
407 None,
408 )
409 .await?;
410 Ok(u)
411 })
412 })
413 .await?;
414
415 let user_map = self.load_user_briefs(&[updated.poster_id]).await?;
416 let poster = user_map.get(&updated.poster_id).cloned();
417
418 Ok(CommentDto {
419 id: updated.id,
420 issue_id: updated.issue_id,
421 parent_id: updated.parent_id,
422 poster,
423 content: updated.content,
424 created_at: updated.created_at,
425 updated_at: updated.updated_at,
426 })
427 }
428
429 pub async fn delete_comment(
430 &self,
431 caller: Uuid,
432 space_name: &str,
433 board_id: Uuid,
434 issue_id: Uuid,
435 comment_id: Uuid,
436 ) -> Result<(), IssueError> {
437 let space = lookup_space_by_name(self, space_name).await?;
438 require_member(self, &space, caller).await?;
439 let board = lookup_board(self, &space, board_id).await?;
440 let _ = lookup_issue(self, &board, issue_id).await?;
441
442 let c = comment::Entity::find_by_id(comment_id)
443 .filter(comment::Column::IssueId.eq(Some(issue_id)))
444 .one(self.db().read())
445 .await?
446 .ok_or(IssueError::NotFound)?;
447
448 if c.poster_id != caller {
449 return Err(IssueError::Forbidden);
450 }
451
452 let this = self.clone();
453
454 self.db()
455 .primary()
456 .transaction::<_, (), IssueError>(move |txn| {
457 Box::pin(async move {
458 comment::Entity::delete_by_id(comment_id).exec(txn).await?;
459 this.write_event(
460 txn,
461 issue_id,
462 caller,
463 "comment_deleted",
464 Some(comment_id),
465 None,
466 None,
467 None,
468 )
469 .await?;
470 Ok(())
471 })
472 })
473 .await?;
474
475 Ok(())
476 }
477}
Added lib/issues/src/services/content_history.rs lib/issues/src/services/content_history.rs +104 −0 View lib/issues/src/services/content_history.rs
@@ -0,0 +1,104 @@
1use models::issue::issue_content_history;
2use sea_orm::{
3 ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
4};
5use serde::{Deserialize, Serialize};
6use space::services::spaces::UserBriefDto;
7use time::OffsetDateTime;
8use utoipa::{IntoParams, ToSchema};
9use uuid::Uuid;
10
11use crate::errors::IssueError;
12use crate::services::service::IssueService;
13use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
14
15#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
16pub struct ContentHistoryDto {
17 pub id: Uuid,
18 pub issue_id: Uuid,
19 pub poster: Option<UserBriefDto>,
20 pub comment_id: Option<Uuid>,
21 pub content_before: Option<String>,
22 pub content_after: Option<String>,
23 pub edited_at: OffsetDateTime,
24 pub created_at: OffsetDateTime,
25}
26
27#[derive(Debug, Clone, Serialize, ToSchema)]
28pub struct ContentHistoryPage {
29 pub items: Vec<ContentHistoryDto>,
30 pub total: i64,
31}
32
33#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
34pub struct ListContentHistoryQuery {
35 pub comment_id: Option<Uuid>,
36 pub limit: Option<u64>,
37 pub offset: Option<u64>,
38}
39
40#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
41pub struct ContentHistoryPath {
42 pub space_name: String,
43 pub board_id: Uuid,
44 pub issue_id: Uuid,
45}
46
47impl IssueService {
48 pub async fn list_content_history(
49 &self,
50 caller: Uuid,
51 space_name: &str,
52 board_id: Uuid,
53 issue_id: Uuid,
54 q: ListContentHistoryQuery,
55 ) -> Result<ContentHistoryPage, IssueError> {
56 let space = lookup_space_by_name(self, space_name).await?;
57 require_member(self, &space, caller).await?;
58 let board = lookup_board(self, &space, board_id).await?;
59 let _ = lookup_issue(self, &board, issue_id).await?;
60
61 let max_pagination = self.config().issues.max_pagination;
62 let default_pagination = self.config().issues.default_pagination;
63 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
64 let offset = q.offset.unwrap_or(0);
65
66 let mut select = issue_content_history::Entity::find()
67 .filter(issue_content_history::Column::IssueId.eq(issue_id));
68
69 if let Some(cid) = q.comment_id {
70 select = select.filter(issue_content_history::Column::CommentId.eq(Some(cid)));
71 }
72
73 select = select.order_by_desc(issue_content_history::Column::EditedAt);
74
75 let total = select.clone().count(self.db().read()).await? as i64;
76 let rows = select
77 .limit(limit)
78 .offset(offset)
79 .all(self.db().read())
80 .await?;
81
82 let poster_ids: Vec<Uuid> = rows.iter().map(|h| h.poster_id).collect();
83 let user_map = self.load_user_briefs(&poster_ids).await?;
84
85 let items = rows
86 .into_iter()
87 .map(|h| {
88 let poster = user_map.get(&h.poster_id).cloned();
89 ContentHistoryDto {
90 id: h.id,
91 issue_id: h.issue_id,
92 poster,
93 comment_id: h.comment_id,
94 content_before: h.content_before,
95 content_after: h.content_after,
96 edited_at: h.edited_at,
97 created_at: h.created_at,
98 }
99 })
100 .collect();
101
102 Ok(ContentHistoryPage { items, total })
103 }
104}
Added lib/issues/src/services/dependencies.rs lib/issues/src/services/dependencies.rs +185 −0 View lib/issues/src/services/dependencies.rs
@@ -0,0 +1,185 @@
1use models::issue::issue_dependency;
2use sea_orm::{
3 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set,
4 TransactionTrait,
5};
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8use utoipa::{IntoParams, ToSchema};
9use uuid::Uuid;
10
11use crate::errors::IssueError;
12use crate::services::service::IssueService;
13use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
14
15#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
16pub struct DependencyDto {
17 pub id: Uuid,
18 pub issue_id: Uuid,
19 pub dependency_id: Uuid,
20 pub created_at: OffsetDateTime,
21}
22
23impl From<issue_dependency::Model> for DependencyDto {
24 fn from(m: issue_dependency::Model) -> Self {
25 Self {
26 id: m.id,
27 issue_id: m.issue_id,
28 dependency_id: m.dependency_id,
29 created_at: m.created_at,
30 }
31 }
32}
33
34#[derive(Debug, Clone, Deserialize, ToSchema)]
35pub struct CreateDependencyCmd {
36 pub dependency_id: Uuid,
37}
38
39#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
40pub struct DependencyPath {
41 pub space_name: String,
42 pub board_id: Uuid,
43 pub issue_id: Uuid,
44 pub dep_id: Uuid,
45}
46
47impl IssueService {
48 pub async fn create_dependency(
49 &self,
50 caller: Uuid,
51 space_name: &str,
52 board_id: Uuid,
53 issue_id: Uuid,
54 cmd: CreateDependencyCmd,
55 ) -> Result<DependencyDto, IssueError> {
56 let space = lookup_space_by_name(self, space_name).await?;
57 require_member(self, &space, caller).await?;
58 let board = lookup_board(self, &space, board_id).await?;
59 let _ = lookup_issue(self, &board, issue_id).await?;
60
61 if cmd.dependency_id == issue_id {
62 return Err(IssueError::BadRequest("issue cannot depend on itself".to_string()));
63 }
64
65 let _ = lookup_issue(self, &board, cmd.dependency_id).await?;
66
67 let existing = issue_dependency::Entity::find()
68 .filter(issue_dependency::Column::IssueId.eq(issue_id))
69 .filter(issue_dependency::Column::DependencyId.eq(cmd.dependency_id))
70 .one(self.db().read())
71 .await?;
72
73 if existing.is_some() {
74 return Err(IssueError::Conflict("dependency already exists".to_string()));
75 }
76
77 let now = OffsetDateTime::now_utc();
78 let dep_id = Uuid::now_v7();
79 let dep_target = cmd.dependency_id;
80 let this = self.clone();
81
82 let inserted = self
83 .db()
84 .primary()
85 .transaction::<_, issue_dependency::Model, IssueError>(move |txn| {
86 Box::pin(async move {
87 let row = issue_dependency::ActiveModel {
88 id: Set(dep_id),
89 issue_id: Set(issue_id),
90 dependency_id: Set(dep_target),
91 created_at: Set(now),
92 };
93 let ins = row.insert(txn).await?;
94
95 this.write_event(
96 txn,
97 issue_id,
98 caller,
99 "dependency_added",
100 None,
101 None,
102 None,
103 Some(dep_target.to_string()),
104 )
105 .await?;
106
107 Ok(ins)
108 })
109 })
110 .await?;
111
112 Ok(DependencyDto::from(inserted))
113 }
114
115 pub async fn list_dependencies(
116 &self,
117 caller: Uuid,
118 space_name: &str,
119 board_id: Uuid,
120 issue_id: Uuid,
121 ) -> Result<Vec<DependencyDto>, IssueError> {
122 let space = lookup_space_by_name(self, space_name).await?;
123 require_member(self, &space, caller).await?;
124 let board = lookup_board(self, &space, board_id).await?;
125 let _ = lookup_issue(self, &board, issue_id).await?;
126
127 let rows = issue_dependency::Entity::find()
128 .filter(issue_dependency::Column::IssueId.eq(issue_id))
129 .order_by_asc(issue_dependency::Column::CreatedAt)
130 .all(self.db().read())
131 .await?;
132
133 Ok(rows.into_iter().map(DependencyDto::from).collect())
134 }
135
136 pub async fn delete_dependency(
137 &self,
138 caller: Uuid,
139 space_name: &str,
140 board_id: Uuid,
141 issue_id: Uuid,
142 dep_id: Uuid,
143 ) -> Result<(), IssueError> {
144 let space = lookup_space_by_name(self, space_name).await?;
145 require_member(self, &space, caller).await?;
146 let board = lookup_board(self, &space, board_id).await?;
147 let _ = lookup_issue(self, &board, issue_id).await?;
148
149 let dep = issue_dependency::Entity::find_by_id(dep_id)
150 .filter(issue_dependency::Column::IssueId.eq(issue_id))
151 .one(self.db().read())
152 .await?
153 .ok_or(IssueError::NotFound)?;
154
155 let dep_target = dep.dependency_id;
156 let this = self.clone();
157
158 self.db()
159 .primary()
160 .transaction::<_, (), IssueError>(move |txn| {
161 Box::pin(async move {
162 issue_dependency::Entity::delete_by_id(dep_id)
163 .exec(txn)
164 .await?;
165
166 this.write_event(
167 txn,
168 issue_id,
169 caller,
170 "dependency_removed",
171 None,
172 None,
173 None,
174 Some(dep_target.to_string()),
175 )
176 .await?;
177
178 Ok(())
179 })
180 })
181 .await?;
182
183 Ok(())
184 }
185}
Added lib/issues/src/services/events.rs lib/issues/src/services/events.rs +106 −0 View lib/issues/src/services/events.rs
@@ -0,0 +1,106 @@
1use models::issue::issue_event_line;
2use sea_orm::{
3 ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
4};
5use serde::{Deserialize, Serialize};
6use space::services::spaces::UserBriefDto;
7use time::OffsetDateTime;
8use utoipa::{IntoParams, ToSchema};
9use uuid::Uuid;
10
11use crate::errors::IssueError;
12use crate::services::service::IssueService;
13use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
14
15#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
16pub struct EventDto {
17 pub id: Uuid,
18 pub issue_id: Option<Uuid>,
19 pub user: Option<UserBriefDto>,
20 pub event_type: String,
21 pub comment_id: Option<Uuid>,
22 pub commit_sha: Option<String>,
23 pub old_title: Option<String>,
24 pub new_title: Option<String>,
25 pub old_ref: Option<String>,
26 pub new_ref: Option<String>,
27 pub content: Option<String>,
28 pub created_at: OffsetDateTime,
29}
30
31#[derive(Debug, Clone, Serialize, ToSchema)]
32pub struct EventPage {
33 pub items: Vec<EventDto>,
34 pub total: i64,
35}
36
37#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
38pub struct ListEventQuery {
39 pub limit: Option<u64>,
40 pub offset: Option<u64>,
41}
42
43#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
44pub struct EventPath {
45 pub space_name: String,
46 pub board_id: Uuid,
47 pub issue_id: Uuid,
48}
49
50impl IssueService {
51 pub async fn list_events(
52 &self,
53 caller: Uuid,
54 space_name: &str,
55 board_id: Uuid,
56 issue_id: Uuid,
57 q: ListEventQuery,
58 ) -> Result<EventPage, IssueError> {
59 let space = lookup_space_by_name(self, space_name).await?;
60 require_member(self, &space, caller).await?;
61 let board = lookup_board(self, &space, board_id).await?;
62 let _ = lookup_issue(self, &board, issue_id).await?;
63
64 let max_pagination = self.config().issues.max_pagination;
65 let default_pagination = self.config().issues.default_pagination;
66 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
67 let offset = q.offset.unwrap_or(0);
68
69 let select = issue_event_line::Entity::find()
70 .filter(issue_event_line::Column::IssueId.eq(Some(issue_id)))
71 .order_by_desc(issue_event_line::Column::CreatedAt);
72
73 let total = select.clone().count(self.db().read()).await? as i64;
74 let rows = select
75 .limit(limit)
76 .offset(offset)
77 .all(self.db().read())
78 .await?;
79
80 let user_ids: Vec<Uuid> = rows.iter().map(|e| e.user_id).collect();
81 let user_map = self.load_user_briefs(&user_ids).await?;
82
83 let items = rows
84 .into_iter()
85 .map(|e| {
86 let user = user_map.get(&e.user_id).cloned();
87 EventDto {
88 id: e.id,
89 issue_id: e.issue_id,
90 user,
91 event_type: e.event_type,
92 comment_id: e.comment_id,
93 commit_sha: e.commit_sha,
94 old_title: e.old_title,
95 new_title: e.new_title,
96 old_ref: e.old_ref,
97 new_ref: e.new_ref,
98 content: e.content,
99 created_at: e.created_at,
100 }
101 })
102 .collect();
103
104 Ok(EventPage { items, total })
105 }
106}
Added lib/issues/src/services/issue_users.rs lib/issues/src/services/issue_users.rs +157 −0 View lib/issues/src/services/issue_users.rs
@@ -0,0 +1,157 @@
1use models::issue::issue_user;
2use sea_orm::{
3 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
4 QuerySelect, Set,
5};
6use serde::{Deserialize, Serialize};
7use space::services::spaces::UserBriefDto;
8use time::OffsetDateTime;
9use utoipa::{IntoParams, ToSchema};
10use uuid::Uuid;
11
12use crate::errors::IssueError;
13use crate::services::service::IssueService;
14use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
15
16#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
17pub struct IssueUserDto {
18 pub id: Uuid,
19 pub uid: Uuid,
20 pub user: Option<UserBriefDto>,
21 pub issue_id: Uuid,
22 pub is_read: bool,
23 pub created_at: OffsetDateTime,
24}
25
26#[derive(Debug, Clone, Serialize, ToSchema)]
27pub struct IssueUserPage {
28 pub items: Vec<IssueUserDto>,
29 pub total: i64,
30}
31
32#[derive(Debug, Clone, Deserialize, ToSchema)]
33pub struct UpdateIssueUserCmd {
34 #[serde(default)]
35 pub is_read: Option<bool>,
36}
37
38#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
39pub struct ListIssueUserQuery {
40 pub limit: Option<u64>,
41 pub offset: Option<u64>,
42}
43
44#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
45pub struct IssueUserPath {
46 pub space_name: String,
47 pub board_id: Uuid,
48 pub issue_id: Uuid,
49 pub uid: Uuid,
50}
51
52impl IssueService {
53 pub async fn list_issue_users(
54 &self,
55 caller: Uuid,
56 space_name: &str,
57 board_id: Uuid,
58 issue_id: Uuid,
59 q: ListIssueUserQuery,
60 ) -> Result<IssueUserPage, IssueError> {
61 let space = lookup_space_by_name(self, space_name).await?;
62 require_member(self, &space, caller).await?;
63 let board = lookup_board(self, &space, board_id).await?;
64 let _ = lookup_issue(self, &board, issue_id).await?;
65
66 let max_pagination = self.config().issues.max_pagination;
67 let default_pagination = self.config().issues.default_pagination;
68 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
69 let offset = q.offset.unwrap_or(0);
70
71 let select = issue_user::Entity::find()
72 .filter(issue_user::Column::IssueId.eq(issue_id))
73 .order_by_desc(issue_user::Column::CreatedAt);
74
75 let total = select.clone().count(self.db().read()).await? as i64;
76 let rows = select
77 .limit(limit)
78 .offset(offset)
79 .all(self.db().read())
80 .await?;
81
82 let uids: Vec<Uuid> = rows.iter().map(|u| u.uid).collect();
83 let user_map = self.load_user_briefs(&uids).await?;
84
85 let items = rows
86 .into_iter()
87 .map(|u| {
88 let user = user_map.get(&u.uid).cloned();
89 IssueUserDto {
90 id: u.id,
91 uid: u.uid,
92 user,
93 issue_id: u.issue_id,
94 is_read: u.is_read,
95 created_at: u.created_at,
96 }
97 })
98 .collect();
99
100 Ok(IssueUserPage { items, total })
101 }
102
103 pub async fn update_issue_user(
104 &self,
105 caller: Uuid,
106 space_name: &str,
107 board_id: Uuid,
108 issue_id: Uuid,
109 uid: Uuid,
110 cmd: UpdateIssueUserCmd,
111 ) -> Result<IssueUserDto, IssueError> {
112 let space = lookup_space_by_name(self, space_name).await?;
113 require_member(self, &space, caller).await?;
114 let board = lookup_board(self, &space, board_id).await?;
115 let _ = lookup_issue(self, &board, issue_id).await?;
116
117 if caller != uid {
118 return Err(IssueError::Forbidden);
119 }
120
121 let existing = issue_user::Entity::find()
122 .filter(issue_user::Column::Uid.eq(uid))
123 .filter(issue_user::Column::IssueId.eq(issue_id))
124 .one(self.db().read())
125 .await?;
126
127 let now = OffsetDateTime::now_utc();
128 let updated = if let Some(row) = existing {
129 let mut am: issue_user::ActiveModel = row.into();
130 if let Some(r) = cmd.is_read {
131 am.is_read = Set(r);
132 }
133 am.update(self.db().write()).await?
134 } else {
135 let row = issue_user::ActiveModel {
136 id: Set(Uuid::now_v7()),
137 uid: Set(uid),
138 issue_id: Set(issue_id),
139 is_read: Set(cmd.is_read.unwrap_or(false)),
140 created_at: Set(now),
141 };
142 row.insert(self.db().write()).await?
143 };
144
145 let user_map = self.load_user_briefs(&[uid]).await?;
146 let user = user_map.get(&uid).cloned();
147
148 Ok(IssueUserDto {
149 id: updated.id,
150 uid: updated.uid,
151 user,
152 issue_id: updated.issue_id,
153 is_read: updated.is_read,
154 created_at: updated.created_at,
155 })
156 }
157}
Added lib/issues/src/services/issues.rs lib/issues/src/services/issues.rs +765 −0 View lib/issues/src/services/issues.rs
@@ -0,0 +1,765 @@
1use models::issue::comment;
2use models::issue::issue_content_history;
3use models::issue::issue_dependency;
4use models::issue::issue_event_line;
5use models::issue::issue_item;
6use models::issue::issue_member;
7use models::issue::issue_pin;
8use models::issue::issue_reactions;
9use models::issue::issue_relation_label;
10use models::issue::issue_relation_pull;
11use models::issue::issue_relation_repo;
12use models::issue::issue_user;
13use models::issue::issue_watch;
14use models::issue::issues;
15use models::issue::milestone;
16use models::issue::stopwatch;
17use models::issue::tracked_time;
18use models::users::user_notifications::NotificationType;
19use sea_orm::{
20 ActiveModelTrait, ColumnTrait, Condition, ConnectionTrait, EntityTrait, PaginatorTrait,
21 QueryFilter, QueryOrder, QuerySelect, Set, TransactionTrait,
22};
23use serde::{Deserialize, Serialize};
24use space::services::spaces::UserBriefDto;
25use time::OffsetDateTime;
26use utoipa::{IntoParams, ToSchema};
27use uuid::Uuid;
28
29use crate::errors::IssueError;
30use crate::services::service::IssueService;
31use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
34#[serde(rename_all = "snake_case")]
35pub enum IssueState {
36 Open,
37 Closed,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
41pub struct IssueDto {
42 pub id: Uuid,
43 pub board_id: Uuid,
44 pub autoincrement_id: i32,
45 pub author: Option<UserBriefDto>,
46 pub title: String,
47 pub description: Option<String>,
48 pub body: String,
49 pub milestone_id: Option<Uuid>,
50 pub state: IssueState,
51 pub created_at: OffsetDateTime,
52 pub updated_at: Option<OffsetDateTime>,
53 pub closed_at: Option<OffsetDateTime>,
54}
55
56#[derive(Debug, Clone, Serialize, ToSchema)]
57pub struct IssuePage {
58 pub items: Vec<IssueDto>,
59 pub total: i64,
60}
61
62#[derive(Debug, Clone, Deserialize, ToSchema)]
63pub struct CreateIssueCmd {
64 pub title: String,
65 #[serde(default)]
66 pub description: Option<String>,
67 #[serde(default)]
68 pub body: String,
69 #[serde(default)]
70 pub milestone_id: Option<Uuid>,
71}
72
73#[derive(Debug, Clone, Deserialize, ToSchema)]
74pub struct UpdateIssueCmd {
75 #[serde(default)]
76 pub title: Option<String>,
77 #[serde(default)]
78 pub description: Option<Option<String>>,
79 #[serde(default)]
80 pub body: Option<String>,
81 #[serde(default)]
82 pub milestone_id: Option<Option<Uuid>>,
83}
84
85#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
86pub struct ListIssueQuery {
87 pub state: Option<IssueState>,
88 pub author_id: Option<Uuid>,
89 pub milestone_id: Option<Uuid>,
90 pub q: Option<String>,
91 pub limit: Option<u64>,
92 pub offset: Option<u64>,
93}
94
95#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
96pub struct IssuePath {
97 pub space_name: String,
98 pub board_id: Uuid,
99 pub issue_id: Uuid,
100}
101
102pub async fn delete_issue_cascade_in_txn(
103 txn: &impl ConnectionTrait,
104 issue_id: Uuid,
105) -> Result<(), IssueError> {
106 issue_pin::Entity::delete_many()
107 .filter(issue_pin::Column::IssueId.eq(issue_id))
108 .exec(txn)
109 .await?;
110
111 let comment_rows = comment::Entity::find()
112 .filter(comment::Column::IssueId.eq(issue_id))
113 .all(txn)
114 .await?;
115 let comment_ids: Vec<Uuid> = comment_rows.into_iter().map(|c| c.id).collect();
116
117 if !comment_ids.is_empty() {
118 issue_reactions::Entity::delete_many()
119 .filter(issue_reactions::Column::CommentId.is_in(comment_ids))
120 .exec(txn)
121 .await?;
122 }
123
124 issue_reactions::Entity::delete_many()
125 .filter(issue_reactions::Column::IssueId.eq(issue_id))
126 .exec(txn)
127 .await?;
128
129 comment::Entity::delete_many()
130 .filter(comment::Column::IssueId.eq(issue_id))
131 .exec(txn)
132 .await?;
133
134 issue_event_line::Entity::delete_many()
135 .filter(issue_event_line::Column::IssueId.eq(issue_id))
136 .exec(txn)
137 .await?;
138
139 issue_member::Entity::delete_many()
140 .filter(issue_member::Column::IssueId.eq(issue_id))
141 .exec(txn)
142 .await?;
143
144 issue_relation_label::Entity::delete_many()
145 .filter(issue_relation_label::Column::IssueId.eq(issue_id))
146 .exec(txn)
147 .await?;
148
149 issue_relation_pull::Entity::delete_many()
150 .filter(issue_relation_pull::Column::IssueId.eq(issue_id))
151 .exec(txn)
152 .await?;
153
154 issue_relation_repo::Entity::delete_many()
155 .filter(issue_relation_repo::Column::IssueId.eq(issue_id))
156 .exec(txn)
157 .await?;
158
159 issue_user::Entity::delete_many()
160 .filter(issue_user::Column::IssueId.eq(issue_id))
161 .exec(txn)
162 .await?;
163
164 issue_watch::Entity::delete_many()
165 .filter(issue_watch::Column::IssueId.eq(issue_id))
166 .exec(txn)
167 .await?;
168
169 issue_dependency::Entity::delete_many()
170 .filter(
171 Condition::any()
172 .add(issue_dependency::Column::IssueId.eq(issue_id))
173 .add(issue_dependency::Column::DependencyId.eq(issue_id)),
174 )
175 .exec(txn)
176 .await?;
177
178 stopwatch::Entity::delete_many()
179 .filter(stopwatch::Column::IssueId.eq(issue_id))
180 .exec(txn)
181 .await?;
182
183 tracked_time::Entity::delete_many()
184 .filter(tracked_time::Column::IssueId.eq(issue_id))
185 .exec(txn)
186 .await?;
187
188 issue_content_history::Entity::delete_many()
189 .filter(issue_content_history::Column::IssueId.eq(issue_id))
190 .exec(txn)
191 .await?;
192
193 issue_item::Entity::delete_by_id(issue_id).exec(txn).await?;
194
195 Ok(())
196}
197
198impl IssueService {
199 pub async fn create_issue(
200 &self,
201 caller: Uuid,
202 space_name: &str,
203 board_id: Uuid,
204 cmd: CreateIssueCmd,
205 ) -> Result<IssueDto, IssueError> {
206 let space = lookup_space_by_name(self, space_name).await?;
207 require_member(self, &space, caller).await?;
208 let board = lookup_board(self, &space, board_id).await?;
209
210 let title = cmd.title.trim();
211 if title.is_empty() || title.chars().count() > self.config().issues.max_title_len {
212 return Err(IssueError::BadRequest("issue title is empty or too long".to_string()));
213 }
214 if cmd.body.chars().count() > self.config().issues.max_body_len {
215 return Err(IssueError::BadRequest("issue body is too long".to_string()));
216 }
217
218 if let Some(mid) = cmd.milestone_id {
219 milestone::Entity::find_by_id(mid)
220 .filter(milestone::Column::CollectionId.eq(board.id))
221 .one(self.db().read())
222 .await?
223 .ok_or_else(|| {
224 IssueError::BadRequest("milestone does not belong to this board".to_string())
225 })?;
226 }
227
228 let full_text = format!("{} {}", title, cmd.body);
229 let mentioned_uids = self
230 .collect_mentioned_uids(&space, &full_text, caller)
231 .await?;
232
233 let now = OffsetDateTime::now_utc();
234 let issue_id = Uuid::now_v7();
235 let title_str = title.to_string();
236 let desc_opt = cmd.description.clone();
237 let body_str = cmd.body.clone();
238 let milestone_opt = cmd.milestone_id;
239 let space_lower_for_txn = space.lower_name.clone();
240 let mentioned_for_txn = mentioned_uids.clone();
241 let this = self.clone();
242
243 let inserted = self
244 .db()
245 .primary()
246 .transaction::<_, issue_item::Model, IssueError>(move |txn| {
247 let title_clone = title_str.clone();
248 let mentioned_clone = mentioned_for_txn;
249 let space_name_clone = space_lower_for_txn;
250 Box::pin(async move {
251 let _ = issues::Entity::find_by_id(board.id)
252 .lock_exclusive()
253 .one(txn)
254 .await?;
255
256 let max_row = issue_item::Entity::find()
257 .filter(issue_item::Column::CollectionId.eq(board.id))
258 .order_by_desc(issue_item::Column::AutoincrementId)
259 .one(txn)
260 .await?;
261 let next_num = max_row.map(|m| m.autoincrement_id + 1).unwrap_or(1);
262
263 let row = issue_item::ActiveModel {
264 id: Set(issue_id),
265 collection_id: Set(board.id),
266 autoincrement_id: Set(next_num),
267 author_id: Set(caller),
268 title: Set(title_clone.clone()),
269 description: Set(desc_opt),
270 body: Set(body_str),
271 milestone_id: Set(milestone_opt),
272 created_at: Set(now),
273 updated_at: Set(None),
274 closed_at: Set(None),
275 };
276 let ins = row.insert(txn).await?;
277
278 this.write_event(
279 txn,
280 issue_id,
281 caller,
282 "created",
283 None,
284 None,
285 Some(title_clone.clone()),
286 None,
287 )
288 .await?;
289
290 for m_uid in mentioned_clone {
291 let existing_member = issue_member::Entity::find()
292 .filter(issue_member::Column::IssueId.eq(issue_id))
293 .filter(issue_member::Column::UserId.eq(m_uid))
294 .filter(
295 issue_member::Column::Kind
296 .eq(models::issue::issue_member::IssueMemberKind::Mentioned),
297 )
298 .one(txn)
299 .await?;
300 if existing_member.is_none() {
301 let m_row = issue_member::ActiveModel {
302 id: Set(Uuid::now_v7()),
303 issue_id: Set(issue_id),
304 user_id: Set(m_uid),
305 kind: Set(
306 models::issue::issue_member::IssueMemberKind::Mentioned,
307 ),
308 created_at: Set(now),
309 updated_at: Set(None),
310 };
311 m_row.insert(txn).await?;
312 }
313
314 let data = serde_json::json!({
315 "space_name": space_name_clone,
316 "board_id": board.id,
317 "issue_id": issue_id,
318 "autoincrement_id": next_num,
319 });
320 this.notify_user(
321 txn,
322 m_uid,
323 NotificationType::Mention,
324 format!("You were mentioned in issue #{next_num}: {title_clone}"),
325 Some(title_clone.clone()),
326 Some(data),
327 Some(caller),
328 )
329 .await?;
330 }
331
332 Ok(ins)
333 })
334 })
335 .await?;
336
337 if !mentioned_uids.is_empty() {
338 let emails = self.primary_emails(&mentioned_uids).await?;
339 for (_uid, addr) in emails {
340 let subject = format!(
341 "You were mentioned in issue #{}: {}",
342 inserted.autoincrement_id, inserted.title
343 );
344 let link = format!(
345 "{}/issues/{}/{}/{}",
346 self.config().web.web_base_url,
347 space.lower_name,
348 board.id,
349 inserted.id
350 );
351 let text = format!("{}\n\nView issue: {}", subject, link);
352 let html = format!(
353 r#"<p>{}</p><p><a href="{}">View Issue</a></p>"#,
354 subject, link
355 );
356 if let Err(err) = self.send_email(&addr, &subject, &text, &html).await {
357 tracing::warn!(error = %err, "failed to send issue mention email");
358 }
359 }
360 }
361
362 let user_map = self.load_user_briefs(&[inserted.author_id]).await?;
363 let author = user_map.get(&inserted.author_id).cloned();
364
365 Ok(IssueDto {
366 id: inserted.id,
367 board_id: inserted.collection_id,
368 autoincrement_id: inserted.autoincrement_id,
369 author,
370 title: inserted.title,
371 description: inserted.description,
372 body: inserted.body,
373 milestone_id: inserted.milestone_id,
374 state: IssueState::Open,
375 created_at: inserted.created_at,
376 updated_at: inserted.updated_at,
377 closed_at: inserted.closed_at,
378 })
379 }
380
381 pub async fn list_issues(
382 &self,
383 caller: Uuid,
384 space_name: &str,
385 board_id: Uuid,
386 q: ListIssueQuery,
387 ) -> Result<IssuePage, IssueError> {
388 let space = lookup_space_by_name(self, space_name).await?;
389 require_member(self, &space, caller).await?;
390 let board = lookup_board(self, &space, board_id).await?;
391
392 let max_pagination = self.config().issues.max_pagination;
393 let default_pagination = self.config().issues.default_pagination;
394 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
395 let offset = q.offset.unwrap_or(0);
396
397 let mut select = issue_item::Entity::find()
398 .filter(issue_item::Column::CollectionId.eq(board.id));
399
400 if let Some(state) = q.state {
401 match state {
402 IssueState::Open => {
403 select = select.filter(issue_item::Column::ClosedAt.is_null());
404 }
405 IssueState::Closed => {
406 select = select.filter(issue_item::Column::ClosedAt.is_not_null());
407 }
408 }
409 }
410
411 if let Some(author) = q.author_id {
412 select = select.filter(issue_item::Column::AuthorId.eq(author));
413 }
414
415 if let Some(milestone) = q.milestone_id {
416 select = select.filter(issue_item::Column::MilestoneId.eq(Some(milestone)));
417 }
418
419 if let Some(ref search) = q.q {
420 let kw = search.trim();
421 if !kw.is_empty() {
422 select = select.filter(issue_item::Column::Title.contains(kw));
423 }
424 }
425
426 select = select.order_by_desc(issue_item::Column::CreatedAt);
427
428 let total = select.clone().count(self.db().read()).await? as i64;
429 let rows = select
430 .limit(limit)
431 .offset(offset)
432 .all(self.db().read())
433 .await?;
434
435 let author_ids: Vec<Uuid> = rows.iter().map(|i| i.author_id).collect();
436 let user_map = self.load_user_briefs(&author_ids).await?;
437
438 let items = rows
439 .into_iter()
440 .map(|i| {
441 let author = user_map.get(&i.author_id).cloned();
442 let state = if i.closed_at.is_some() {
443 IssueState::Closed
444 } else {
445 IssueState::Open
446 };
447 IssueDto {
448 id: i.id,
449 board_id: i.collection_id,
450 autoincrement_id: i.autoincrement_id,
451 author,
452 title: i.title,
453 description: i.description,
454 body: i.body,
455 milestone_id: i.milestone_id,
456 state,
457 created_at: i.created_at,
458 updated_at: i.updated_at,
459 closed_at: i.closed_at,
460 }
461 })
462 .collect();
463
464 Ok(IssuePage { items, total })
465 }
466
467 pub async fn get_issue(
468 &self,
469 caller: Uuid,
470 space_name: &str,
471 board_id: Uuid,
472 issue_id: Uuid,
473 ) -> Result<IssueDto, IssueError> {
474 let space = lookup_space_by_name(self, space_name).await?;
475 require_member(self, &space, caller).await?;
476 let board = lookup_board(self, &space, board_id).await?;
477 let item = lookup_issue(self, &board, issue_id).await?;
478
479 let user_map = self.load_user_briefs(&[item.author_id]).await?;
480 let author = user_map.get(&item.author_id).cloned();
481 let state = if item.closed_at.is_some() {
482 IssueState::Closed
483 } else {
484 IssueState::Open
485 };
486
487 Ok(IssueDto {
488 id: item.id,
489 board_id: item.collection_id,
490 autoincrement_id: item.autoincrement_id,
491 author,
492 title: item.title,
493 description: item.description,
494 body: item.body,
495 milestone_id: item.milestone_id,
496 state,
497 created_at: item.created_at,
498 updated_at: item.updated_at,
499 closed_at: item.closed_at,
500 })
501 }
502
503 pub async fn update_issue(
504 &self,
505 caller: Uuid,
506 space_name: &str,
507 board_id: Uuid,
508 issue_id: Uuid,
509 cmd: UpdateIssueCmd,
510 ) -> Result<IssueDto, IssueError> {
511 let space = lookup_space_by_name(self, space_name).await?;
512 require_member(self, &space, caller).await?;
513 let board = lookup_board(self, &space, board_id).await?;
514 let item = lookup_issue(self, &board, issue_id).await?;
515
516 let old_title = item.title.clone();
517 let old_milestone = item.milestone_id;
518
519 let mut title_changed = None;
520 if let Some(new_title) = cmd.title {
521 let t = new_title.trim();
522 if t.is_empty() || t.chars().count() > self.config().issues.max_title_len {
523 return Err(IssueError::BadRequest("issue title is empty or too long".to_string()));
524 }
525 if t != old_title {
526 title_changed = Some(t.to_string());
527 }
528 }
529
530 if cmd.body.as_ref().is_some_and(|b| b.chars().count() > self.config().issues.max_body_len) {
531 return Err(IssueError::BadRequest("issue body is too long".to_string()));
532 }
533
534 let mut milestone_changed = false;
535 let mut new_milestone_val = old_milestone;
536 if let Some(mid_opt) = cmd.milestone_id {
537 if let Some(mid) = mid_opt {
538 milestone::Entity::find_by_id(mid)
539 .filter(milestone::Column::CollectionId.eq(board.id))
540 .one(self.db().read())
541 .await?
542 .ok_or_else(|| {
543 IssueError::BadRequest(
544 "milestone does not belong to this board".to_string(),
545 )
546 })?;
547 }
548 if mid_opt != old_milestone {
549 milestone_changed = true;
550 new_milestone_val = mid_opt;
551 }
552 }
553
554 let now = OffsetDateTime::now_utc();
555 let mut am: issue_item::ActiveModel = item.into();
556 if let Some(ref t) = title_changed {
557 am.title = Set(t.clone());
558 }
559 if let Some(desc) = cmd.description {
560 am.description = Set(desc);
561 }
562 if let Some(b) = cmd.body {
563 am.body = Set(b);
564 }
565 if milestone_changed {
566 am.milestone_id = Set(new_milestone_val);
567 }
568 am.updated_at = Set(Some(now));
569 let this = self.clone();
570
571 let updated = self
572 .db()
573 .primary()
574 .transaction::<_, issue_item::Model, IssueError>(move |txn| {
575 let title_change_clone = title_changed.clone();
576 Box::pin(async move {
577 let u = am.update(txn).await?;
578
579 if let Some(new_t) = title_change_clone {
580 this.write_event(
581 txn,
582 issue_id,
583 caller,
584 "title_updated",
585 None,
586 Some(old_title),
587 Some(new_t),
588 None,
589 )
590 .await?;
591 }
592
593 if milestone_changed {
594 let old_name = if let Some(omid) = old_milestone {
595 let m = milestone::Entity::find_by_id(omid).one(txn).await?;
596 m.map(|m| m.name).unwrap_or_else(|| omid.to_string())
597 } else {
598 "none".to_string()
599 };
600 let new_name = if let Some(nmid) = new_milestone_val {
601 let m = milestone::Entity::find_by_id(nmid).one(txn).await?;
602 m.map(|m| m.name).unwrap_or_else(|| nmid.to_string())
603 } else {
604 "none".to_string()
605 };
606 let content = format!("{old_name} -> {new_name}");
607 this.write_event(
608 txn,
609 issue_id,
610 caller,
611 "milestone_updated",
612 None,
613 None,
614 None,
615 Some(content),
616 )
617 .await?;
618 }
619
620 Ok(u)
621 })
622 })
623 .await?;
624
625 let user_map = self.load_user_briefs(&[updated.author_id]).await?;
626 let author = user_map.get(&updated.author_id).cloned();
627 let state = if updated.closed_at.is_some() {
628 IssueState::Closed
629 } else {
630 IssueState::Open
631 };
632
633 Ok(IssueDto {
634 id: updated.id,
635 board_id: updated.collection_id,
636 autoincrement_id: updated.autoincrement_id,
637 author,
638 title: updated.title,
639 description: updated.description,
640 body: updated.body,
641 milestone_id: updated.milestone_id,
642 state,
643 created_at: updated.created_at,
644 updated_at: updated.updated_at,
645 closed_at: updated.closed_at,
646 })
647 }
648
649 pub async fn delete_issue(
650 &self,
651 caller: Uuid,
652 space_name: &str,
653 board_id: Uuid,
654 issue_id: Uuid,
655 ) -> Result<(), IssueError> {
656 let space = lookup_space_by_name(self, space_name).await?;
657 require_member(self, &space, caller).await?;
658 let board = lookup_board(self, &space, board_id).await?;
659 let _ = lookup_issue(self, &board, issue_id).await?;
660
661 self.db()
662 .primary()
663 .transaction::<_, (), IssueError>(|txn| {
664 Box::pin(async move {
665 delete_issue_cascade_in_txn(txn, issue_id).await?;
666 Ok(())
667 })
668 })
669 .await?;
670
671 Ok(())
672 }
673
674 pub async fn close_issue(
675 &self,
676 caller: Uuid,
677 space_name: &str,
678 board_id: Uuid,
679 issue_id: Uuid,
680 closed: bool,
681 ) -> Result<IssueDto, IssueError> {
682 let space = lookup_space_by_name(self, space_name).await?;
683 require_member(self, &space, caller).await?;
684 let board = lookup_board(self, &space, board_id).await?;
685 let item = lookup_issue(self, &board, issue_id).await?;
686
687 let is_currently_closed = item.closed_at.is_some();
688 if is_currently_closed == closed {
689 let user_map = self.load_user_briefs(&[item.author_id]).await?;
690 let author = user_map.get(&item.author_id).cloned();
691 let state = if is_currently_closed {
692 IssueState::Closed
693 } else {
694 IssueState::Open
695 };
696 return Ok(IssueDto {
697 id: item.id,
698 board_id: item.collection_id,
699 autoincrement_id: item.autoincrement_id,
700 author,
701 title: item.title,
702 description: item.description,
703 body: item.body,
704 milestone_id: item.milestone_id,
705 state,
706 created_at: item.created_at,
707 updated_at: item.updated_at,
708 closed_at: item.closed_at,
709 });
710 }
711
712 let now = OffsetDateTime::now_utc();
713 let mut am: issue_item::ActiveModel = item.into();
714 am.closed_at = Set(closed.then_some(now));
715 am.updated_at = Set(Some(now));
716
717 let event_type = if closed { "closed" } else { "reopened" };
718 let this = self.clone();
719
720 let updated = self
721 .db()
722 .primary()
723 .transaction::<_, issue_item::Model, IssueError>(move |txn| {
724 Box::pin(async move {
725 let u = am.update(txn).await?;
726 this.write_event(
727 txn,
728 issue_id,
729 caller,
730 event_type,
731 None,
732 None,
733 None,
734 None,
735 )
736 .await?;
737 Ok(u)
738 })
739 })
740 .await?;
741
742 let user_map = self.load_user_briefs(&[updated.author_id]).await?;
743 let author = user_map.get(&updated.author_id).cloned();
744 let state = if updated.closed_at.is_some() {
745 IssueState::Closed
746 } else {
747 IssueState::Open
748 };
749
750 Ok(IssueDto {
751 id: updated.id,
752 board_id: updated.collection_id,
753 autoincrement_id: updated.autoincrement_id,
754 author,
755 title: updated.title,
756 description: updated.description,
757 body: updated.body,
758 milestone_id: updated.milestone_id,
759 state,
760 created_at: updated.created_at,
761 updated_at: updated.updated_at,
762 closed_at: updated.closed_at,
763 })
764 }
765}
Added lib/issues/src/services/labels.rs lib/issues/src/services/labels.rs +475 −0 View lib/issues/src/services/labels.rs
@@ -0,0 +1,475 @@
1use models::issue::issue_label;
2use models::issue::issue_relation_label;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
5 QuerySelect, Set, TransactionTrait,
6};
7use serde::{Deserialize, Serialize};
8use time::OffsetDateTime;
9use utoipa::{IntoParams, ToSchema};
10use uuid::Uuid;
11
12use crate::errors::IssueError;
13use crate::services::service::IssueService;
14use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
15
16#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
17pub struct LabelDto {
18 pub id: Uuid,
19 pub dir_id: Uuid,
20 pub name: String,
21 pub exclusive: bool,
22 pub exclusive_order: i32,
23 pub description: Option<String>,
24 pub color: String,
25 pub is_archived: bool,
26 pub created_at: OffsetDateTime,
27 pub updated_at: Option<OffsetDateTime>,
28}
29
30impl From<issue_label::Model> for LabelDto {
31 fn from(m: issue_label::Model) -> Self {
32 Self {
33 id: m.id,
34 dir_id: m.dir_id,
35 name: m.name,
36 exclusive: m.exclusive,
37 exclusive_order: m.exclusive_order,
38 description: m.description,
39 color: m.color,
40 is_archived: m.is_archived,
41 created_at: m.created_at,
42 updated_at: m.updated_at,
43 }
44 }
45}
46
47#[derive(Debug, Clone, Serialize, ToSchema)]
48pub struct LabelPage {
49 pub items: Vec<LabelDto>,
50 pub total: i64,
51}
52
53#[derive(Debug, Clone, Deserialize, ToSchema)]
54pub struct CreateLabelCmd {
55 pub name: String,
56 pub color: String,
57 #[serde(default)]
58 pub exclusive: Option<bool>,
59 #[serde(default)]
60 pub exclusive_order: Option<i32>,
61 #[serde(default)]
62 pub description: Option<String>,
63}
64
65#[derive(Debug, Clone, Deserialize, ToSchema)]
66pub struct UpdateLabelCmd {
67 #[serde(default)]
68 pub name: Option<String>,
69 #[serde(default)]
70 pub color: Option<String>,
71 #[serde(default)]
72 pub exclusive: Option<bool>,
73 #[serde(default)]
74 pub exclusive_order: Option<i32>,
75 #[serde(default)]
76 pub description: Option<Option<String>>,
77 #[serde(default)]
78 pub is_archived: Option<bool>,
79}
80
81#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
82pub struct ListLabelQuery {
83 pub q: Option<String>,
84 pub is_archived: Option<bool>,
85 pub limit: Option<u64>,
86 pub offset: Option<u64>,
87}
88
89#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
90pub struct LabelPath {
91 pub space_name: String,
92 pub board_id: Uuid,
93 pub label_id: Uuid,
94}
95
96#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
97pub struct IssueLabelPath {
98 pub space_name: String,
99 pub board_id: Uuid,
100 pub issue_id: Uuid,
101 pub label_id: Uuid,
102}
103
104fn validate_hex_color(color: &str, max_len: usize) -> bool {
105 let clean = color.trim();
106 if clean.len() != 7 || clean.len() > max_len || !clean.starts_with('#') {
107 return false;
108 }
109 clean[1..].chars().all(|c| c.is_ascii_hexdigit())
110}
111
112impl IssueService {
113 pub async fn create_label(
114 &self,
115 caller: Uuid,
116 space_name: &str,
117 board_id: Uuid,
118 cmd: CreateLabelCmd,
119 ) -> Result<LabelDto, IssueError> {
120 let space = lookup_space_by_name(self, space_name).await?;
121 require_member(self, &space, caller).await?;
122 let board = lookup_board(self, &space, board_id).await?;
123
124 let name = cmd.name.trim();
125 if name.is_empty() || name.chars().count() > self.config().issues.max_label_name_len {
126 return Err(IssueError::BadRequest("label name is empty or too long".to_string()));
127 }
128
129 let color = cmd.color.trim();
130 if !validate_hex_color(color, self.config().issues.max_label_color_len) {
131 return Err(IssueError::BadRequest(
132 "invalid color format: must be #rrggbb hex code".to_string(),
133 ));
134 }
135
136 if let Some(eo) = cmd.exclusive_order
137 && eo < 0
138 {
139 return Err(IssueError::BadRequest(
140 "exclusive_order must be non-negative".to_string(),
141 ));
142 }
143
144 let now = OffsetDateTime::now_utc();
145 let row = issue_label::ActiveModel {
146 id: Set(Uuid::now_v7()),
147 dir_id: Set(board.dir_id),
148 name: Set(name.to_string()),
149 exclusive: Set(cmd.exclusive.unwrap_or(false)),
150 exclusive_order: Set(cmd.exclusive_order.unwrap_or(0)),
151 description: Set(cmd.description),
152 color: Set(color.to_string()),
153 is_archived: Set(false),
154 created_at: Set(now),
155 updated_at: Set(None),
156 };
157
158 let inserted = row.insert(self.db().write()).await?;
159 Ok(LabelDto::from(inserted))
160 }
161
162 pub async fn list_labels(
163 &self,
164 caller: Uuid,
165 space_name: &str,
166 board_id: Uuid,
167 q: ListLabelQuery,
168 ) -> Result<LabelPage, IssueError> {
169 let space = lookup_space_by_name(self, space_name).await?;
170 require_member(self, &space, caller).await?;
171 let board = lookup_board(self, &space, board_id).await?;
172
173 let max_pagination = self.config().issues.max_pagination;
174 let default_pagination = self.config().issues.default_pagination;
175 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
176 let offset = q.offset.unwrap_or(0);
177
178 let mut select = issue_label::Entity::find()
179 .filter(issue_label::Column::DirId.eq(board.dir_id));
180
181 if let Some(archived) = q.is_archived {
182 select = select.filter(issue_label::Column::IsArchived.eq(archived));
183 }
184
185 if let Some(ref search) = q.q {
186 let kw = search.trim();
187 if !kw.is_empty() {
188 select = select.filter(issue_label::Column::Name.contains(kw));
189 }
190 }
191
192 select = select.order_by_desc(issue_label::Column::CreatedAt);
193
194 let total = select.clone().count(self.db().read()).await? as i64;
195 let rows = select
196 .limit(limit)
197 .offset(offset)
198 .all(self.db().read())
199 .await?;
200
201 let items = rows.into_iter().map(LabelDto::from).collect();
202 Ok(LabelPage { items, total })
203 }
204
205 pub async fn get_label(
206 &self,
207 caller: Uuid,
208 space_name: &str,
209 board_id: Uuid,
210 label_id: Uuid,
211 ) -> Result<LabelDto, IssueError> {
212 let space = lookup_space_by_name(self, space_name).await?;
213 require_member(self, &space, caller).await?;
214 let board = lookup_board(self, &space, board_id).await?;
215
216 let l = issue_label::Entity::find_by_id(label_id)
217 .filter(issue_label::Column::DirId.eq(board.dir_id))
218 .one(self.db().read())
219 .await?
220 .ok_or(IssueError::NotFound)?;
221
222 Ok(LabelDto::from(l))
223 }
224
225 pub async fn update_label(
226 &self,
227 caller: Uuid,
228 space_name: &str,
229 board_id: Uuid,
230 label_id: Uuid,
231 cmd: UpdateLabelCmd,
232 ) -> Result<LabelDto, IssueError> {
233 let space = lookup_space_by_name(self, space_name).await?;
234 require_member(self, &space, caller).await?;
235 let board = lookup_board(self, &space, board_id).await?;
236
237 let l = issue_label::Entity::find_by_id(label_id)
238 .filter(issue_label::Column::DirId.eq(board.dir_id))
239 .one(self.db().read())
240 .await?
241 .ok_or(IssueError::NotFound)?;
242
243 let mut am: issue_label::ActiveModel = l.into();
244
245 if let Some(new_name) = cmd.name {
246 let name = new_name.trim();
247 if name.is_empty() || name.chars().count() > self.config().issues.max_label_name_len {
248 return Err(IssueError::BadRequest("label name is empty or too long".to_string()));
249 }
250 am.name = Set(name.to_string());
251 }
252
253 if let Some(new_color) = cmd.color {
254 let color = new_color.trim();
255 if !validate_hex_color(color, self.config().issues.max_label_color_len) {
256 return Err(IssueError::BadRequest(
257 "invalid color format: must be #rrggbb hex code".to_string(),
258 ));
259 }
260 am.color = Set(color.to_string());
261 }
262
263 if let Some(ex) = cmd.exclusive {
264 am.exclusive = Set(ex);
265 }
266 if let Some(eo) = cmd.exclusive_order {
267 if eo < 0 {
268 return Err(IssueError::BadRequest(
269 "exclusive_order must be non-negative".to_string(),
270 ));
271 }
272 am.exclusive_order = Set(eo);
273 }
274 if let Some(desc) = cmd.description {
275 am.description = Set(desc);
276 }
277 if let Some(archived) = cmd.is_archived {
278 am.is_archived = Set(archived);
279 }
280
281 let now = OffsetDateTime::now_utc();
282 am.updated_at = Set(Some(now));
283
284 let updated = am.update(self.db().write()).await?;
285 Ok(LabelDto::from(updated))
286 }
287
288 pub async fn delete_label(
289 &self,
290 caller: Uuid,
291 space_name: &str,
292 board_id: Uuid,
293 label_id: Uuid,
294 ) -> Result<(), IssueError> {
295 let space = lookup_space_by_name(self, space_name).await?;
296 require_member(self, &space, caller).await?;
297 let board = lookup_board(self, &space, board_id).await?;
298
299 let _ = issue_label::Entity::find_by_id(label_id)
300 .filter(issue_label::Column::DirId.eq(board.dir_id))
301 .one(self.db().read())
302 .await?
303 .ok_or(IssueError::NotFound)?;
304
305 self.db()
306 .primary()
307 .transaction::<_, (), IssueError>(|txn| {
308 Box::pin(async move {
309 issue_relation_label::Entity::delete_many()
310 .filter(issue_relation_label::Column::LabelId.eq(label_id))
311 .exec(txn)
312 .await?;
313 issue_label::Entity::delete_by_id(label_id).exec(txn).await?;
314 Ok(())
315 })
316 })
317 .await?;
318
319 Ok(())
320 }
321
322 pub async fn list_labels_for_issue(
323 &self,
324 caller: Uuid,
325 space_name: &str,
326 board_id: Uuid,
327 issue_id: Uuid,
328 ) -> Result<Vec<LabelDto>, IssueError> {
329 let space = lookup_space_by_name(self, space_name).await?;
330 require_member(self, &space, caller).await?;
331 let board = lookup_board(self, &space, board_id).await?;
332 let _ = lookup_issue(self, &board, issue_id).await?;
333
334 let rels = issue_relation_label::Entity::find()
335 .filter(issue_relation_label::Column::IssueId.eq(issue_id))
336 .all(self.db().read())
337 .await?;
338
339 if rels.is_empty() {
340 return Ok(Vec::new());
341 }
342
343 let label_ids: Vec<Uuid> = rels.into_iter().map(|r| r.label_id).collect();
344 let labels = issue_label::Entity::find()
345 .filter(issue_label::Column::Id.is_in(label_ids))
346 .all(self.db().read())
347 .await?;
348
349 Ok(labels.into_iter().map(LabelDto::from).collect())
350 }
351
352 pub async fn bind_label(
353 &self,
354 caller: Uuid,
355 space_name: &str,
356 board_id: Uuid,
357 issue_id: Uuid,
358 label_id: Uuid,
359 ) -> Result<(), IssueError> {
360 let space = lookup_space_by_name(self, space_name).await?;
361 require_member(self, &space, caller).await?;
362 let board = lookup_board(self, &space, board_id).await?;
363 let _ = lookup_issue(self, &board, issue_id).await?;
364
365 let label = issue_label::Entity::find_by_id(label_id)
366 .filter(issue_label::Column::DirId.eq(board.dir_id))
367 .one(self.db().read())
368 .await?
369 .ok_or(IssueError::NotFound)?;
370
371 let existing = issue_relation_label::Entity::find()
372 .filter(issue_relation_label::Column::IssueId.eq(issue_id))
373 .filter(issue_relation_label::Column::LabelId.eq(label_id))
374 .one(self.db().read())
375 .await?;
376
377 if existing.is_some() {
378 return Err(IssueError::Conflict("label already bound to issue".to_string()));
379 }
380
381 let now = OffsetDateTime::now_utc();
382 let label_name = label.name.clone();
383 let this = self.clone();
384
385 self.db()
386 .primary()
387 .transaction::<_, (), IssueError>(move |txn| {
388 let name_clone = label_name.clone();
389 Box::pin(async move {
390 let row = issue_relation_label::ActiveModel {
391 id: Set(Uuid::now_v7()),
392 issue_id: Set(issue_id),
393 label_id: Set(label_id),
394 created_at: Set(now),
395 };
396 row.insert(txn).await?;
397
398 this.write_event(
399 txn,
400 issue_id,
401 caller,
402 "label_added",
403 None,
404 None,
405 None,
406 Some(name_clone),
407 )
408 .await?;
409
410 Ok(())
411 })
412 })
413 .await?;
414
415 Ok(())
416 }
417
418 pub async fn unbind_label(
419 &self,
420 caller: Uuid,
421 space_name: &str,
422 board_id: Uuid,
423 issue_id: Uuid,
424 label_id: Uuid,
425 ) -> Result<(), IssueError> {
426 let space = lookup_space_by_name(self, space_name).await?;
427 require_member(self, &space, caller).await?;
428 let board = lookup_board(self, &space, board_id).await?;
429 let _ = lookup_issue(self, &board, issue_id).await?;
430
431 let label = issue_label::Entity::find_by_id(label_id)
432 .filter(issue_label::Column::DirId.eq(board.dir_id))
433 .one(self.db().read())
434 .await?
435 .ok_or(IssueError::NotFound)?;
436
437 let rel = issue_relation_label::Entity::find()
438 .filter(issue_relation_label::Column::IssueId.eq(issue_id))
439 .filter(issue_relation_label::Column::LabelId.eq(label_id))
440 .one(self.db().read())
441 .await?
442 .ok_or(IssueError::NotFound)?;
443
444 let label_name = label.name.clone();
445 let this = self.clone();
446
447 self.db()
448 .primary()
449 .transaction::<_, (), IssueError>(move |txn| {
450 let name_clone = label_name.clone();
451 Box::pin(async move {
452 issue_relation_label::Entity::delete_by_id(rel.id)
453 .exec(txn)
454 .await?;
455
456 this.write_event(
457 txn,
458 issue_id,
459 caller,
460 "label_removed",
461 None,
462 None,
463 None,
464 Some(name_clone),
465 )
466 .await?;
467
468 Ok(())
469 })
470 })
471 .await?;
472
473 Ok(())
474 }
475}
Added lib/issues/src/services/members.rs lib/issues/src/services/members.rs +290 −0 View lib/issues/src/services/members.rs
@@ -0,0 +1,290 @@
1use models::issue::issue_member::{self, IssueMemberKind};
2use models::users::user;
3use models::users::user_notifications::NotificationType;
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
6 QuerySelect, Set, TransactionTrait,
7};
8use serde::{Deserialize, Serialize};
9use space::services::spaces::UserBriefDto;
10use time::OffsetDateTime;
11use utoipa::{IntoParams, ToSchema};
12use uuid::Uuid;
13
14use crate::errors::IssueError;
15use crate::services::service::IssueService;
16use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
17
18#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
19pub struct IssueMemberDto {
20 pub id: Uuid,
21 pub issue_id: Uuid,
22 pub user: Option<UserBriefDto>,
23 pub kind: IssueMemberKind,
24 pub created_at: OffsetDateTime,
25 pub updated_at: Option<OffsetDateTime>,
26}
27
28#[derive(Debug, Clone, Serialize, ToSchema)]
29pub struct IssueMemberPage {
30 pub items: Vec<IssueMemberDto>,
31 pub total: i64,
32}
33
34#[derive(Debug, Clone, Deserialize, ToSchema)]
35pub struct CreateIssueMemberCmd {
36 pub user_id: Uuid,
37 pub kind: IssueMemberKind,
38}
39
40#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
41pub struct ListIssueMemberQuery {
42 pub kind: Option<IssueMemberKind>,
43 pub limit: Option<u64>,
44 pub offset: Option<u64>,
45}
46
47#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
48pub struct IssueMemberPath {
49 pub space_name: String,
50 pub board_id: Uuid,
51 pub issue_id: Uuid,
52 pub member_id: Uuid,
53}
54
55impl IssueService {
56 pub async fn create_member(
57 &self,
58 caller: Uuid,
59 space_name: &str,
60 board_id: Uuid,
61 issue_id: Uuid,
62 cmd: CreateIssueMemberCmd,
63 ) -> Result<IssueMemberDto, IssueError> {
64 let space = lookup_space_by_name(self, space_name).await?;
65 require_member(self, &space, caller).await?;
66 let board = lookup_board(self, &space, board_id).await?;
67 let item = lookup_issue(self, &board, issue_id).await?;
68
69 let target_user = user::Entity::find_by_id(cmd.user_id)
70 .one(self.db().read())
71 .await?
72 .ok_or(IssueError::NotFound)?;
73
74 let existing = issue_member::Entity::find()
75 .filter(issue_member::Column::IssueId.eq(issue_id))
76 .filter(issue_member::Column::UserId.eq(cmd.user_id))
77 .filter(issue_member::Column::Kind.eq(cmd.kind))
78 .one(self.db().read())
79 .await?;
80 if existing.is_some() {
81 return Err(IssueError::Conflict(
82 "member already exists with this role".to_string(),
83 ));
84 }
85
86 let now = OffsetDateTime::now_utc();
87 let member_id = Uuid::now_v7();
88 let kind_enum = cmd.kind;
89 let target_uid = cmd.user_id;
90 let this = self.clone();
91 let item_title_for_txn = item.title.clone();
92 let space_lower_for_txn = space.lower_name.clone();
93
94 let inserted = self
95 .db()
96 .primary()
97 .transaction::<_, issue_member::Model, IssueError>(move |txn| {
98 let space_name_clone = space_lower_for_txn;
99 let issue_num = item.autoincrement_id;
100 let issue_title_clone = item_title_for_txn;
101 Box::pin(async move {
102 let row = issue_member::ActiveModel {
103 id: Set(member_id),
104 issue_id: Set(issue_id),
105 user_id: Set(target_uid),
106 kind: Set(kind_enum),
107 created_at: Set(now),
108 updated_at: Set(None),
109 };
110 let ins = row.insert(txn).await?;
111
112 let content_str = format!("{:?} {}", kind_enum, target_uid);
113 this.write_event(
114 txn,
115 issue_id,
116 caller,
117 "member_added",
118 None,
119 None,
120 None,
121 Some(content_str),
122 )
123 .await?;
124
125 if kind_enum == IssueMemberKind::Assignee && target_uid != caller {
126 let data = serde_json::json!({
127 "space_name": space_name_clone,
128 "board_id": board.id,
129 "issue_id": issue_id,
130 "autoincrement_id": issue_num,
131 });
132 this.notify_user(
133 txn,
134 target_uid,
135 NotificationType::Assignment,
136 format!("You were assigned to issue #{issue_num}: {issue_title_clone}"),
137 Some(issue_title_clone.clone()),
138 Some(data),
139 Some(caller),
140 )
141 .await?;
142 }
143
144 Ok(ins)
145 })
146 })
147 .await?;
148
149 if kind_enum == IssueMemberKind::Assignee && target_uid != caller {
150 let emails = self.primary_emails(&[target_uid]).await?;
151 if let Some(addr) = emails.get(&target_uid) {
152 let subject = format!(
153 "You were assigned to issue #{}: {}",
154 item.autoincrement_id, item.title
155 );
156 let link = format!(
157 "{}/issues/{}/{}/{}",
158 self.config().web.web_base_url,
159 space.lower_name,
160 board.id,
161 item.id
162 );
163 let text = format!("{}\n\nView issue: {}", subject, link);
164 let html = format!(
165 r#"<p>{}</p><p><a href="{}">View Issue</a></p>"#,
166 subject, link
167 );
168 if let Err(err) = self.send_email(addr, &subject, &text, &html).await {
169 tracing::warn!(error = %err, "failed to send issue assignment email");
170 }
171 }
172 }
173
174 let user_brief = UserBriefDto::from(target_user);
175
176 Ok(IssueMemberDto {
177 id: inserted.id,
178 issue_id: inserted.issue_id,
179 user: Some(user_brief),
180 kind: inserted.kind,
181 created_at: inserted.created_at,
182 updated_at: inserted.updated_at,
183 })
184 }
185
186 pub async fn list_members(
187 &self,
188 caller: Uuid,
189 space_name: &str,
190 board_id: Uuid,
191 issue_id: Uuid,
192 q: ListIssueMemberQuery,
193 ) -> Result<IssueMemberPage, IssueError> {
194 let space = lookup_space_by_name(self, space_name).await?;
195 require_member(self, &space, caller).await?;
196 let board = lookup_board(self, &space, board_id).await?;
197 let _ = lookup_issue(self, &board, issue_id).await?;
198
199 let max_pagination = self.config().issues.max_pagination;
200 let default_pagination = self.config().issues.default_pagination;
201 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
202 let offset = q.offset.unwrap_or(0);
203
204 let mut select = issue_member::Entity::find()
205 .filter(issue_member::Column::IssueId.eq(issue_id));
206
207 if let Some(kind) = q.kind {
208 select = select.filter(issue_member::Column::Kind.eq(kind));
209 }
210
211 select = select.order_by_asc(issue_member::Column::CreatedAt);
212
213 let total = select.clone().count(self.db().read()).await? as i64;
214 let rows = select
215 .limit(limit)
216 .offset(offset)
217 .all(self.db().read())
218 .await?;
219
220 let user_ids: Vec<Uuid> = rows.iter().map(|m| m.user_id).collect();
221 let user_map = self.load_user_briefs(&user_ids).await?;
222
223 let items = rows
224 .into_iter()
225 .map(|m| {
226 let user = user_map.get(&m.user_id).cloned();
227 IssueMemberDto {
228 id: m.id,
229 issue_id: m.issue_id,
230 user,
231 kind: m.kind,
232 created_at: m.created_at,
233 updated_at: m.updated_at,
234 }
235 })
236 .collect();
237
238 Ok(IssueMemberPage { items, total })
239 }
240
241 pub async fn delete_member(
242 &self,
243 caller: Uuid,
244 space_name: &str,
245 board_id: Uuid,
246 issue_id: Uuid,
247 member_id: Uuid,
248 ) -> Result<(), IssueError> {
249 let space = lookup_space_by_name(self, space_name).await?;
250 require_member(self, &space, caller).await?;
251 let board = lookup_board(self, &space, board_id).await?;
252 let _ = lookup_issue(self, &board, issue_id).await?;
253
254 let m = issue_member::Entity::find_by_id(member_id)
255 .filter(issue_member::Column::IssueId.eq(issue_id))
256 .one(self.db().read())
257 .await?
258 .ok_or(IssueError::NotFound)?;
259
260 let content_str = format!("{:?} {}", m.kind, m.user_id);
261 let this = self.clone();
262
263 self.db()
264 .primary()
265 .transaction::<_, (), IssueError>(move |txn| {
266 Box::pin(async move {
267 issue_member::Entity::delete_by_id(member_id)
268 .exec(txn)
269 .await?;
270
271 this.write_event(
272 txn,
273 issue_id,
274 caller,
275 "member_removed",
276 None,
277 None,
278 None,
279 Some(content_str),
280 )
281 .await?;
282
283 Ok(())
284 })
285 })
286 .await?;
287
288 Ok(())
289 }
290}
Added lib/issues/src/services/milestones.rs lib/issues/src/services/milestones.rs +311 −0 View lib/issues/src/services/milestones.rs
@@ -0,0 +1,311 @@
1use models::issue::issue_item;
2use models::issue::milestone;
3use sea_orm::sea_query::Expr;
4use sea_orm::{
5 ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder,
6 QuerySelect, Set, TransactionTrait,
7};
8use serde::{Deserialize, Serialize};
9use time::OffsetDateTime;
10use utoipa::{IntoParams, ToSchema};
11use uuid::Uuid;
12
13use crate::errors::IssueError;
14use crate::services::service::IssueService;
15use crate::share::lookup::{lookup_board, lookup_space_by_name, require_member};
16
17#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
18pub struct MilestoneDto {
19 pub id: Uuid,
20 pub board_id: Uuid,
21 pub name: String,
22 pub description: Option<String>,
23 pub is_closed: bool,
24 pub due_at: Option<OffsetDateTime>,
25 pub nums_issues: i64,
26 pub nums_closed_issues: i64,
27 pub created_at: OffsetDateTime,
28 pub updated_at: Option<OffsetDateTime>,
29}
30
31#[derive(Debug, Clone, Serialize, ToSchema)]
32pub struct MilestonePage {
33 pub items: Vec<MilestoneDto>,
34 pub total: i64,
35}
36
37#[derive(Debug, Clone, Deserialize, ToSchema)]
38pub struct CreateMilestoneCmd {
39 pub name: String,
40 #[serde(default)]
41 pub description: Option<String>,
42 #[serde(default)]
43 pub due_at: Option<OffsetDateTime>,
44}
45
46#[derive(Debug, Clone, Deserialize, ToSchema)]
47pub struct UpdateMilestoneCmd {
48 #[serde(default)]
49 pub name: Option<String>,
50 #[serde(default)]
51 pub description: Option<Option<String>>,
52 #[serde(default)]
53 pub due_at: Option<Option<OffsetDateTime>>,
54 #[serde(default)]
55 pub is_closed: Option<bool>,
56}
57
58#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
59pub struct ListMilestoneQuery {
60 pub is_closed: Option<bool>,
61 pub q: Option<String>,
62 pub limit: Option<u64>,
63 pub offset: Option<u64>,
64}
65
66#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
67pub struct MilestonePath {
68 pub space_name: String,
69 pub board_id: Uuid,
70 pub milestone_id: Uuid,
71}
72
73impl IssueService {
74 async fn build_milestone_dto(
75 &self,
76 m: milestone::Model,
77 ) -> Result<MilestoneDto, IssueError> {
78 let total_issues = issue_item::Entity::find()
79 .filter(issue_item::Column::MilestoneId.eq(Some(m.id)))
80 .count(self.db().read())
81 .await? as i64;
82
83 let closed_issues = issue_item::Entity::find()
84 .filter(issue_item::Column::MilestoneId.eq(Some(m.id)))
85 .filter(issue_item::Column::ClosedAt.is_not_null())
86 .count(self.db().read())
87 .await? as i64;
88
89 Ok(MilestoneDto {
90 id: m.id,
91 board_id: m.collection_id,
92 name: m.name,
93 description: m.description,
94 is_closed: m.is_closed,
95 due_at: m.due_at,
96 nums_issues: total_issues,
97 nums_closed_issues: closed_issues,
98 created_at: m.created_at,
99 updated_at: m.updated_at,
100 })
101 }
102
103 pub async fn create_milestone(
104 &self,
105 caller: Uuid,
106 space_name: &str,
107 board_id: Uuid,
108 cmd: CreateMilestoneCmd,
109 ) -> Result<MilestoneDto, IssueError> {
110 let space = lookup_space_by_name(self, space_name).await?;
111 require_member(self, &space, caller).await?;
112 let board = lookup_board(self, &space, board_id).await?;
113
114 let name = cmd.name.trim();
115 if name.is_empty() || name.chars().count() > self.config().issues.max_milestone_title_len {
116 return Err(IssueError::BadRequest("milestone name is empty or too long".to_string()));
117 }
118
119 if cmd.description.as_ref().is_some_and(|d| d.chars().count() > self.config().issues.max_milestone_description_len) {
120 return Err(IssueError::BadRequest(
121 "milestone description is too long".to_string(),
122 ));
123 }
124
125 let now = OffsetDateTime::now_utc();
126 let row = milestone::ActiveModel {
127 id: Set(Uuid::now_v7()),
128 collection_id: Set(board.id),
129 name: Set(name.to_string()),
130 description: Set(cmd.description),
131 is_closed: Set(false),
132 due_at: Set(cmd.due_at),
133 nums_issues: Set(0),
134 nums_closed_issues: Set(0),
135 created_at: Set(now),
136 updated_at: Set(None),
137 };
138
139 let inserted = row.insert(self.db().write()).await?;
140 Ok(MilestoneDto {
141 id: inserted.id,
142 board_id: inserted.collection_id,
143 name: inserted.name,
144 description: inserted.description,
145 is_closed: inserted.is_closed,
146 due_at: inserted.due_at,
147 nums_issues: 0,
148 nums_closed_issues: 0,
149 created_at: inserted.created_at,
150 updated_at: inserted.updated_at,
151 })
152 }
153
154 pub async fn list_milestones(
155 &self,
156 caller: Uuid,
157 space_name: &str,
158 board_id: Uuid,
159 q: ListMilestoneQuery,
160 ) -> Result<MilestonePage, IssueError> {
161 let space = lookup_space_by_name(self, space_name).await?;
162 require_member(self, &space, caller).await?;
163 let board = lookup_board(self, &space, board_id).await?;
164
165 let max_pagination = self.config().issues.max_pagination;
166 let default_pagination = self.config().issues.default_pagination;
167 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
168 let offset = q.offset.unwrap_or(0);
169
170 let mut select = milestone::Entity::find()
171 .filter(milestone::Column::CollectionId.eq(board.id));
172
173 if let Some(closed) = q.is_closed {
174 select = select.filter(milestone::Column::IsClosed.eq(closed));
175 }
176
177 if let Some(ref search) = q.q {
178 let kw = search.trim();
179 if !kw.is_empty() {
180 select = select.filter(milestone::Column::Name.contains(kw));
181 }
182 }
183
184 select = select.order_by_desc(milestone::Column::CreatedAt);
185
186 let total = select.clone().count(self.db().read()).await? as i64;
187 let rows = select
188 .limit(limit)
189 .offset(offset)
190 .all(self.db().read())
191 .await?;
192
193 let mut items = Vec::with_capacity(rows.len());
194 for m in rows {
195 items.push(self.build_milestone_dto(m).await?);
196 }
197
198 Ok(MilestonePage { items, total })
199 }
200
201 pub async fn get_milestone(
202 &self,
203 caller: Uuid,
204 space_name: &str,
205 board_id: Uuid,
206 milestone_id: Uuid,
207 ) -> Result<MilestoneDto, IssueError> {
208 let space = lookup_space_by_name(self, space_name).await?;
209 require_member(self, &space, caller).await?;
210 let board = lookup_board(self, &space, board_id).await?;
211
212 let m = milestone::Entity::find_by_id(milestone_id)
213 .filter(milestone::Column::CollectionId.eq(board.id))
214 .one(self.db().read())
215 .await?
216 .ok_or(IssueError::NotFound)?;
217
218 self.build_milestone_dto(m).await
219 }
220
221 pub async fn update_milestone(
222 &self,
223 caller: Uuid,
224 space_name: &str,
225 board_id: Uuid,
226 milestone_id: Uuid,
227 cmd: UpdateMilestoneCmd,
228 ) -> Result<MilestoneDto, IssueError> {
229 let space = lookup_space_by_name(self, space_name).await?;
230 require_member(self, &space, caller).await?;
231 let board = lookup_board(self, &space, board_id).await?;
232
233 let m = milestone::Entity::find_by_id(milestone_id)
234 .filter(milestone::Column::CollectionId.eq(board.id))
235 .one(self.db().read())
236 .await?
237 .ok_or(IssueError::NotFound)?;
238
239 let mut am: milestone::ActiveModel = m.into();
240
241 if let Some(new_name) = cmd.name {
242 let name = new_name.trim();
243 if name.is_empty()
244 || name.chars().count() > self.config().issues.max_milestone_title_len
245 {
246 return Err(IssueError::BadRequest(
247 "milestone name is empty or too long".to_string(),
248 ));
249 }
250 am.name = Set(name.to_string());
251 }
252
253 if let Some(desc) = cmd.description {
254 if desc.as_ref().is_some_and(|d| d.chars().count() > self.config().issues.max_milestone_description_len) {
255 return Err(IssueError::BadRequest(
256 "milestone description is too long".to_string(),
257 ));
258 }
259 am.description = Set(desc);
260 }
261
262 if let Some(due) = cmd.due_at {
263 am.due_at = Set(due);
264 }
265 if let Some(closed) = cmd.is_closed {
266 am.is_closed = Set(closed);
267 }
268
269 let now = OffsetDateTime::now_utc();
270 am.updated_at = Set(Some(now));
271
272 let updated = am.update(self.db().write()).await?;
273 self.build_milestone_dto(updated).await
274 }
275
276 pub async fn delete_milestone(
277 &self,
278 caller: Uuid,
279 space_name: &str,
280 board_id: Uuid,
281 milestone_id: Uuid,
282 ) -> Result<(), IssueError> {
283 let space = lookup_space_by_name(self, space_name).await?;
284 require_member(self, &space, caller).await?;
285 let board = lookup_board(self, &space, board_id).await?;
286
287 let _ = milestone::Entity::find_by_id(milestone_id)
288 .filter(milestone::Column::CollectionId.eq(board.id))
289 .one(self.db().read())
290 .await?
291 .ok_or(IssueError::NotFound)?;
292
293 self.db()
294 .primary()
295 .transaction::<_, (), IssueError>(|txn| {
296 Box::pin(async move {
297 issue_item::Entity::update_many()
298 .filter(issue_item::Column::MilestoneId.eq(Some(milestone_id)))
299 .col_expr(issue_item::Column::MilestoneId, Expr::value(None::<Uuid>))
300 .exec(txn)
301 .await?;
302
303 milestone::Entity::delete_by_id(milestone_id).exec(txn).await?;
304 Ok(())
305 })
306 })
307 .await?;
308
309 Ok(())
310 }
311}
Added lib/issues/src/services/mod.rs lib/issues/src/services/mod.rs +17 −0 View lib/issues/src/services/mod.rs
@@ -0,0 +1,17 @@
1pub mod boards;
2pub mod comments;
3pub mod content_history;
4pub mod dependencies;
5pub mod events;
6pub mod issue_users;
7pub mod issues;
8pub mod labels;
9pub mod members;
10pub mod milestones;
11pub mod pins;
12pub mod reactions;
13pub mod repo_relations;
14pub mod service;
15pub mod stopwatch;
16pub mod tracked_time;
17pub mod watches;
Added lib/issues/src/services/pins.rs lib/issues/src/services/pins.rs +134 −0 View lib/issues/src/services/pins.rs
@@ -0,0 +1,134 @@
1use models::issue::issue_pin;
2use sea_orm::{
3 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set,
4};
5use serde::{Deserialize, Serialize};
6use time::OffsetDateTime;
7use utoipa::{IntoParams, ToSchema};
8use uuid::Uuid;
9
10use crate::errors::IssueError;
11use crate::services::service::IssueService;
12use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
13
14#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
15pub struct PinDto {
16 pub id: Uuid,
17 pub board_id: Uuid,
18 pub issue_id: Option<Uuid>,
19 pub pin_order: i32,
20 pub created_at: OffsetDateTime,
21}
22
23impl From<issue_pin::Model> for PinDto {
24 fn from(m: issue_pin::Model) -> Self {
25 Self {
26 id: m.id,
27 board_id: m.collection_id,
28 issue_id: m.issue_id,
29 pin_order: m.pin_order,
30 created_at: m.created_at,
31 }
32 }
33}
34
35#[derive(Debug, Clone, Deserialize, ToSchema)]
36pub struct CreatePinCmd {
37 pub issue_id: Uuid,
38 pub pin_order: i32,
39}
40
41#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
42pub struct PinPath {
43 pub space_name: String,
44 pub board_id: Uuid,
45 pub pin_id: Uuid,
46}
47
48impl IssueService {
49 pub async fn create_pin(
50 &self,
51 caller: Uuid,
52 space_name: &str,
53 board_id: Uuid,
54 cmd: CreatePinCmd,
55 ) -> Result<PinDto, IssueError> {
56 let space = lookup_space_by_name(self, space_name).await?;
57 require_member(self, &space, caller).await?;
58 let board = lookup_board(self, &space, board_id).await?;
59 let _ = lookup_issue(self, &board, cmd.issue_id).await?;
60
61 if cmd.pin_order < 0 {
62 return Err(IssueError::BadRequest(
63 "pin_order must be non-negative".to_string(),
64 ));
65 }
66
67 let existing = issue_pin::Entity::find()
68 .filter(issue_pin::Column::CollectionId.eq(board.id))
69 .filter(issue_pin::Column::IssueId.eq(Some(cmd.issue_id)))
70 .one(self.db().read())
71 .await?;
72
73 if existing.is_some() {
74 return Err(IssueError::Conflict("issue already pinned".to_string()));
75 }
76
77 let now = OffsetDateTime::now_utc();
78 let row = issue_pin::ActiveModel {
79 id: Set(Uuid::now_v7()),
80 collection_id: Set(board.id),
81 issue_id: Set(Some(cmd.issue_id)),
82 project_id: Set(None),
83 pin_order: Set(cmd.pin_order),
84 created_at: Set(now),
85 };
86
87 let inserted = row.insert(self.db().write()).await?;
88 Ok(PinDto::from(inserted))
89 }
90
91 pub async fn list_pins(
92 &self,
93 caller: Uuid,
94 space_name: &str,
95 board_id: Uuid,
96 ) -> Result<Vec<PinDto>, IssueError> {
97 let space = lookup_space_by_name(self, space_name).await?;
98 require_member(self, &space, caller).await?;
99 let board = lookup_board(self, &space, board_id).await?;
100
101 let rows = issue_pin::Entity::find()
102 .filter(issue_pin::Column::CollectionId.eq(board.id))
103 .order_by_asc(issue_pin::Column::PinOrder)
104 .order_by_asc(issue_pin::Column::CreatedAt)
105 .all(self.db().read())
106 .await?;
107
108 Ok(rows.into_iter().map(PinDto::from).collect())
109 }
110
111 pub async fn delete_pin(
112 &self,
113 caller: Uuid,
114 space_name: &str,
115 board_id: Uuid,
116 pin_id: Uuid,
117 ) -> Result<(), IssueError> {
118 let space = lookup_space_by_name(self, space_name).await?;
119 require_member(self, &space, caller).await?;
120 let board = lookup_board(self, &space, board_id).await?;
121
122 let p = issue_pin::Entity::find_by_id(pin_id)
123 .filter(issue_pin::Column::CollectionId.eq(board.id))
124 .one(self.db().read())
125 .await?
126 .ok_or(IssueError::NotFound)?;
127
128 issue_pin::Entity::delete_by_id(p.id)
129 .exec(self.db().write())
130 .await?;
131
132 Ok(())
133 }
134}
Added lib/issues/src/services/reactions.rs lib/issues/src/services/reactions.rs +287 −0 View lib/issues/src/services/reactions.rs
@@ -0,0 +1,287 @@
1use std::collections::HashMap;
2
3use models::issue::comment;
4use models::issue::issue_reactions;
5use sea_orm::{
6 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set,
7};
8use serde::{Deserialize, Serialize};
9use space::services::spaces::UserBriefDto;
10use time::OffsetDateTime;
11use utoipa::{IntoParams, ToSchema};
12use uuid::Uuid;
13
14use crate::errors::IssueError;
15use crate::services::service::IssueService;
16use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
19#[serde(rename_all = "snake_case")]
20pub enum ReactionScope {
21 Issue,
22 Comment,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
26pub struct ReactionDto {
27 pub id: Uuid,
28 pub scope: ReactionScope,
29 pub target_id: Uuid,
30 pub user: Option<UserBriefDto>,
31 pub emoji: String,
32 pub created_at: OffsetDateTime,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
36pub struct ReactionAggDto {
37 pub emoji: String,
38 pub count: i64,
39 pub user_ids: Vec<Uuid>,
40}
41
42#[derive(Debug, Clone, Deserialize, ToSchema)]
43pub struct CreateReactionCmd {
44 pub emoji: String,
45}
46
47#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
48pub struct ReactionPath {
49 pub space_name: String,
50 pub board_id: Uuid,
51 pub issue_id: Uuid,
52 pub reaction_id: Uuid,
53}
54
55#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
56pub struct CommentReactionPath {
57 pub space_name: String,
58 pub board_id: Uuid,
59 pub issue_id: Uuid,
60 pub comment_id: Uuid,
61}
62
63impl IssueService {
64 fn validate_emoji(&self, emoji: &str) -> Result<String, IssueError> {
65 let clean = emoji.trim();
66 if clean.is_empty() || clean.chars().count() > self.config().issues.max_reaction_emoji_len {
67 return Err(IssueError::BadRequest("emoji is empty or too long".to_string()));
68 }
69 Ok(clean.to_string())
70 }
71
72 pub async fn create_reaction(
73 &self,
74 caller: Uuid,
75 space_name: &str,
76 board_id: Uuid,
77 issue_id: Uuid,
78 cmd: CreateReactionCmd,
79 ) -> Result<ReactionDto, IssueError> {
80 let space = lookup_space_by_name(self, space_name).await?;
81 require_member(self, &space, caller).await?;
82 let board = lookup_board(self, &space, board_id).await?;
83 let _ = lookup_issue(self, &board, issue_id).await?;
84
85 let emoji = self.validate_emoji(&cmd.emoji)?;
86
87 let existing = issue_reactions::Entity::find()
88 .filter(issue_reactions::Column::IssueId.eq(Some(issue_id)))
89 .filter(issue_reactions::Column::UserId.eq(caller))
90 .filter(issue_reactions::Column::Emoji.eq(&emoji))
91 .one(self.db().read())
92 .await?;
93
94 if existing.is_some() {
95 return Err(IssueError::Conflict("reaction already exists".to_string()));
96 }
97
98 let now = OffsetDateTime::now_utc();
99 let row = issue_reactions::ActiveModel {
100 id: Set(Uuid::now_v7()),
101 issue_id: Set(Some(issue_id)),
102 comment_id: Set(None),
103 project_id: Set(None),
104 user_id: Set(caller),
105 emoji: Set(emoji.clone()),
106 created_at: Set(now),
107 };
108
109 let inserted = row.insert(self.db().write()).await?;
110 let user_map = self.load_user_briefs(&[caller]).await?;
111 let user = user_map.get(&caller).cloned();
112
113 Ok(ReactionDto {
114 id: inserted.id,
115 scope: ReactionScope::Issue,
116 target_id: issue_id,
117 user,
118 emoji,
119 created_at: inserted.created_at,
120 })
121 }
122
123 pub async fn create_comment_reaction(
124 &self,
125 caller: Uuid,
126 space_name: &str,
127 board_id: Uuid,
128 issue_id: Uuid,
129 comment_id: Uuid,
130 cmd: CreateReactionCmd,
131 ) -> Result<ReactionDto, IssueError> {
132 let space = lookup_space_by_name(self, space_name).await?;
133 require_member(self, &space, caller).await?;
134 let board = lookup_board(self, &space, board_id).await?;
135 let _ = lookup_issue(self, &board, issue_id).await?;
136
137 let c = comment::Entity::find_by_id(comment_id)
138 .filter(comment::Column::IssueId.eq(Some(issue_id)))
139 .one(self.db().read())
140 .await?
141 .ok_or(IssueError::NotFound)?;
142
143 let emoji = self.validate_emoji(&cmd.emoji)?;
144
145 let existing = issue_reactions::Entity::find()
146 .filter(issue_reactions::Column::CommentId.eq(Some(c.id)))
147 .filter(issue_reactions::Column::UserId.eq(caller))
148 .filter(issue_reactions::Column::Emoji.eq(&emoji))
149 .one(self.db().read())
150 .await?;
151
152 if existing.is_some() {
153 return Err(IssueError::Conflict("reaction already exists".to_string()));
154 }
155
156 let now = OffsetDateTime::now_utc();
157 let row = issue_reactions::ActiveModel {
158 id: Set(Uuid::now_v7()),
159 issue_id: Set(None),
160 comment_id: Set(Some(c.id)),
161 project_id: Set(None),
162 user_id: Set(caller),
163 emoji: Set(emoji.clone()),
164 created_at: Set(now),
165 };
166
167 let inserted = row.insert(self.db().write()).await?;
168 let user_map = self.load_user_briefs(&[caller]).await?;
169 let user = user_map.get(&caller).cloned();
170
171 Ok(ReactionDto {
172 id: inserted.id,
173 scope: ReactionScope::Comment,
174 target_id: c.id,
175 user,
176 emoji,
177 created_at: inserted.created_at,
178 })
179 }
180
181 pub async fn list_reactions(
182 &self,
183 caller: Uuid,
184 space_name: &str,
185 board_id: Uuid,
186 issue_id: Uuid,
187 ) -> Result<Vec<ReactionAggDto>, IssueError> {
188 let space = lookup_space_by_name(self, space_name).await?;
189 require_member(self, &space, caller).await?;
190 let board = lookup_board(self, &space, board_id).await?;
191 let _ = lookup_issue(self, &board, issue_id).await?;
192
193 let rows = issue_reactions::Entity::find()
194 .filter(issue_reactions::Column::IssueId.eq(Some(issue_id)))
195 .all(self.db().read())
196 .await?;
197
198 let mut groups: HashMap<String, Vec<Uuid>> = HashMap::new();
199 for r in rows {
200 groups.entry(r.emoji).or_default().push(r.user_id);
201 }
202
203 let mut result = Vec::new();
204 for (emoji, mut uids) in groups {
205 uids.sort();
206 uids.dedup();
207 result.push(ReactionAggDto {
208 count: uids.len() as i64,
209 emoji,
210 user_ids: uids,
211 });
212 }
213 result.sort_by(|a, b| a.emoji.cmp(&b.emoji));
214
215 Ok(result)
216 }
217
218 pub async fn list_comment_reactions(
219 &self,
220 caller: Uuid,
221 space_name: &str,
222 board_id: Uuid,
223 issue_id: Uuid,
224 comment_id: Uuid,
225 ) -> Result<Vec<ReactionAggDto>, IssueError> {
226 let space = lookup_space_by_name(self, space_name).await?;
227 require_member(self, &space, caller).await?;
228 let board = lookup_board(self, &space, board_id).await?;
229 let _ = lookup_issue(self, &board, issue_id).await?;
230
231 let _ = comment::Entity::find_by_id(comment_id)
232 .filter(comment::Column::IssueId.eq(Some(issue_id)))
233 .one(self.db().read())
234 .await?
235 .ok_or(IssueError::NotFound)?;
236
237 let rows = issue_reactions::Entity::find()
238 .filter(issue_reactions::Column::CommentId.eq(Some(comment_id)))
239 .all(self.db().read())
240 .await?;
241
242 let mut groups: HashMap<String, Vec<Uuid>> = HashMap::new();
243 for r in rows {
244 groups.entry(r.emoji).or_default().push(r.user_id);
245 }
246
247 let mut result = Vec::new();
248 for (emoji, mut uids) in groups {
249 uids.sort();
250 uids.dedup();
251 result.push(ReactionAggDto {
252 count: uids.len() as i64,
253 emoji,
254 user_ids: uids,
255 });
256 }
257 result.sort_by(|a, b| a.emoji.cmp(&b.emoji));
258
259 Ok(result)
260 }
261
262 pub async fn delete_reaction(
263 &self,
264 caller: Uuid,
265 space_name: &str,
266 board_id: Uuid,
267 issue_id: Uuid,
268 reaction_id: Uuid,
269 ) -> Result<(), IssueError> {
270 let space = lookup_space_by_name(self, space_name).await?;
271 require_member(self, &space, caller).await?;
272 let board = lookup_board(self, &space, board_id).await?;
273 let _ = lookup_issue(self, &board, issue_id).await?;
274
275 let r = issue_reactions::Entity::find_by_id(reaction_id)
276 .filter(issue_reactions::Column::UserId.eq(caller))
277 .one(self.db().read())
278 .await?
279 .ok_or(IssueError::NotFound)?;
280
281 issue_reactions::Entity::delete_by_id(r.id)
282 .exec(self.db().write())
283 .await?;
284
285 Ok(())
286 }
287}
Added lib/issues/src/services/repo_relations.rs lib/issues/src/services/repo_relations.rs +134 −0 View lib/issues/src/services/repo_relations.rs
@@ -0,0 +1,134 @@
1use models::issue::issue_relation_repo;
2use models::repo::repos;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set,
5};
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8use utoipa::{IntoParams, ToSchema};
9use uuid::Uuid;
10
11use crate::errors::IssueError;
12use crate::services::service::IssueService;
13use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
14
15#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
16pub struct RepoRelDto {
17 pub id: Uuid,
18 pub issue_id: Uuid,
19 pub repo_id: Uuid,
20 pub created_at: OffsetDateTime,
21}
22
23impl From<issue_relation_repo::Model> for RepoRelDto {
24 fn from(m: issue_relation_repo::Model) -> Self {
25 Self {
26 id: m.id,
27 issue_id: m.issue_id,
28 repo_id: m.repo_id,
29 created_at: m.created_at,
30 }
31 }
32}
33
34#[derive(Debug, Clone, Deserialize, ToSchema)]
35pub struct CreateRepoRelCmd {
36 pub repo_id: Uuid,
37}
38
39#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
40pub struct RepoRelPath {
41 pub space_name: String,
42 pub board_id: Uuid,
43 pub issue_id: Uuid,
44 pub rel_id: Uuid,
45}
46
47impl IssueService {
48 pub async fn create_repo_relation(
49 &self,
50 caller: Uuid,
51 space_name: &str,
52 board_id: Uuid,
53 issue_id: Uuid,
54 cmd: CreateRepoRelCmd,
55 ) -> Result<RepoRelDto, IssueError> {
56 let space = lookup_space_by_name(self, space_name).await?;
57 require_member(self, &space, caller).await?;
58 let board = lookup_board(self, &space, board_id).await?;
59 let _ = lookup_issue(self, &board, issue_id).await?;
60
61 let _ = repos::Entity::find_by_id(cmd.repo_id)
62 .one(self.db().read())
63 .await?
64 .ok_or(IssueError::NotFound)?;
65
66 let existing = issue_relation_repo::Entity::find()
67 .filter(issue_relation_repo::Column::IssueId.eq(issue_id))
68 .filter(issue_relation_repo::Column::RepoId.eq(cmd.repo_id))
69 .one(self.db().read())
70 .await?;
71
72 if existing.is_some() {
73 return Err(IssueError::Conflict("repo relation already exists".to_string()));
74 }
75
76 let now = OffsetDateTime::now_utc();
77 let row = issue_relation_repo::ActiveModel {
78 id: Set(Uuid::now_v7()),
79 issue_id: Set(issue_id),
80 repo_id: Set(cmd.repo_id),
81 created_at: Set(now),
82 };
83
84 let inserted = row.insert(self.db().write()).await?;
85 Ok(RepoRelDto::from(inserted))
86 }
87
88 pub async fn list_repo_relations(
89 &self,
90 caller: Uuid,
91 space_name: &str,
92 board_id: Uuid,
93 issue_id: Uuid,
94 ) -> Result<Vec<RepoRelDto>, IssueError> {
95 let space = lookup_space_by_name(self, space_name).await?;
96 require_member(self, &space, caller).await?;
97 let board = lookup_board(self, &space, board_id).await?;
98 let _ = lookup_issue(self, &board, issue_id).await?;
99
100 let rows = issue_relation_repo::Entity::find()
101 .filter(issue_relation_repo::Column::IssueId.eq(issue_id))
102 .order_by_asc(issue_relation_repo::Column::CreatedAt)
103 .all(self.db().read())
104 .await?;
105
106 Ok(rows.into_iter().map(RepoRelDto::from).collect())
107 }
108
109 pub async fn delete_repo_relation(
110 &self,
111 caller: Uuid,
112 space_name: &str,
113 board_id: Uuid,
114 issue_id: Uuid,
115 rel_id: Uuid,
116 ) -> Result<(), IssueError> {
117 let space = lookup_space_by_name(self, space_name).await?;
118 require_member(self, &space, caller).await?;
119 let board = lookup_board(self, &space, board_id).await?;
120 let _ = lookup_issue(self, &board, issue_id).await?;
121
122 let r = issue_relation_repo::Entity::find_by_id(rel_id)
123 .filter(issue_relation_repo::Column::IssueId.eq(issue_id))
124 .one(self.db().read())
125 .await?
126 .ok_or(IssueError::NotFound)?;
127
128 issue_relation_repo::Entity::delete_by_id(r.id)
129 .exec(self.db().write())
130 .await?;
131
132 Ok(())
133 }
134}
Added lib/issues/src/services/service.rs lib/issues/src/services/service.rs +231 −0 View lib/issues/src/services/service.rs
@@ -0,0 +1,231 @@
1use std::collections::{HashMap, HashSet};
2use std::sync::Arc;
3
4use config::AppConfig;
5use export::database::AppDatabase;
6use export::email::AppEmail;
7use export::AppState;
8use mailbaby::Email;
9use models::issue::issue_event_line;
10use models::space::space_members::{self, MemberStatus};
11use models::space::spaces;
12use models::users::user;
13use models::users::user_emails;
14use models::users::user_notifications::{self, NotificationType};
15use sea_orm::{
16 ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, Set,
17};
18use space::services::spaces::UserBriefDto;
19use time::OffsetDateTime;
20use uuid::Uuid;
21
22use crate::errors::IssueError;
23
24#[derive(Clone)]
25pub struct IssueService {
26 pub state: AppState,
27}
28
29impl IssueService {
30 pub fn new(state: AppState) -> Self {
31 Self { state }
32 }
33
34 pub fn config(&self) -> &AppConfig {
35 &self.state.config
36 }
37
38 pub fn db(&self) -> &Arc<AppDatabase> {
39 &self.state.database
40 }
41
42 pub fn email(&self) -> &AppEmail {
43 &self.state.email
44 }
45
46 pub async fn load_user_briefs(
47 &self,
48 uids: &[Uuid],
49 ) -> Result<HashMap<Uuid, UserBriefDto>, IssueError> {
50 if uids.is_empty() {
51 return Ok(HashMap::new());
52 }
53 let mut set: Vec<Uuid> = uids.to_vec();
54 set.sort();
55 set.dedup();
56 let users = user::Entity::find()
57 .filter(user::Column::Uid.is_in(set))
58 .all(self.db().read())
59 .await?;
60 let map = users
61 .into_iter()
62 .map(|u| (u.uid, UserBriefDto::from(u)))
63 .collect();
64 Ok(map)
65 }
66
67 #[allow(clippy::too_many_arguments)]
68 pub(crate) async fn write_event(
69 &self,
70 txn: &impl ConnectionTrait,
71 issue_id: Uuid,
72 user_id: Uuid,
73 event_type: &str,
74 comment_id: Option<Uuid>,
75 old_title: Option<String>,
76 new_title: Option<String>,
77 content: Option<String>,
78 ) -> Result<(), IssueError> {
79 issue_event_line::ActiveModel {
80 id: Set(Uuid::now_v7()),
81 issue_id: Set(Some(issue_id)),
82 project_id: Set(None),
83 user_id: Set(user_id),
84 event_type: Set(event_type.to_string()),
85 comment_id: Set(comment_id),
86 commit_sha: Set(None),
87 old_title: Set(old_title),
88 new_title: Set(new_title),
89 old_ref: Set(None),
90 new_ref: Set(None),
91 content: Set(content),
92 created_at: Set(OffsetDateTime::now_utc()),
93 }
94 .insert(txn)
95 .await?;
96 Ok(())
97 }
98
99 #[allow(clippy::too_many_arguments)]
100 pub(crate) async fn notify_user(
101 &self,
102 txn: &impl ConnectionTrait,
103 recipient: Uuid,
104 ntype: NotificationType,
105 title: String,
106 body: Option<String>,
107 data: Option<serde_json::Value>,
108 actor_uid: Option<Uuid>,
109 ) -> Result<(), IssueError> {
110 user_notifications::ActiveModel {
111 id: Set(Uuid::now_v7()),
112 user_id: Set(recipient),
113 notification_type: Set(ntype),
114 title: Set(title),
115 body: Set(body),
116 data_json: Set(data),
117 actor_uid: Set(actor_uid),
118 read_at: Set(None),
119 created_at: Set(OffsetDateTime::now_utc()),
120 }
121 .insert(txn)
122 .await?;
123 Ok(())
124 }
125
126 pub(crate) async fn collect_mentioned_uids(
127 &self,
128 space: &spaces::Model,
129 text: &str,
130 exclude: Uuid,
131 ) -> Result<Vec<Uuid>, IssueError> {
132 let members = space_members::Entity::find()
133 .filter(space_members::Column::SpaceId.eq(space.id))
134 .filter(space_members::Column::Status.eq(MemberStatus::Active))
135 .all(self.db().read())
136 .await?;
137
138 let mut member_uids: Vec<Uuid> = members.into_iter().map(|m| m.user_id).collect();
139 if !member_uids.contains(&space.owner_id) {
140 member_uids.push(space.owner_id);
141 }
142
143 if member_uids.is_empty() {
144 return Ok(Vec::new());
145 }
146
147 let users = user::Entity::find()
148 .filter(user::Column::Uid.is_in(member_uids))
149 .all(self.db().read())
150 .await?;
151
152 let mut name_to_uid: HashMap<String, Uuid> = HashMap::new();
153 for u in users {
154 name_to_uid.insert(u.username.to_lowercase(), u.uid);
155 name_to_uid.insert(u.lower_name.to_lowercase(), u.uid);
156 name_to_uid.insert(u.uid.to_string(), u.uid);
157 }
158
159 let mut result_set: HashSet<Uuid> = HashSet::new();
160 let punctuation = [
161 ',', '.', ';', ':', '!', '?', '\'', '"', '“', '”', '`', ')', ']', '}', '>',
162 ];
163
164 for token in text.split_whitespace() {
165 if let Some(rest) = token.strip_prefix('@') {
166 let clean = rest.trim_end_matches(&punctuation[..]);
167 if clean.is_empty() {
168 continue;
169 }
170 let clean_lower = clean.to_lowercase();
171 if let Some(&uid) = name_to_uid.get(&clean_lower)
172 && uid != exclude
173 {
174 result_set.insert(uid);
175 }
176 }
177 }
178
179 let mut uids: Vec<Uuid> = result_set.into_iter().collect();
180 uids.sort();
181 let max_mentions = self.config().issues.max_mentions_per_message;
182 if uids.len() > max_mentions {
183 uids.truncate(max_mentions);
184 }
185
186 Ok(uids)
187 }
188
189 pub(crate) async fn primary_emails(
190 &self,
191 uids: &[Uuid],
192 ) -> Result<HashMap<Uuid, String>, IssueError> {
193 if uids.is_empty() {
194 return Ok(HashMap::new());
195 }
196 let rows = user_emails::Entity::find()
197 .filter(user_emails::Column::UserId.is_in(uids.to_vec()))
198 .filter(user_emails::Column::IsPrimary.eq(true))
199 .filter(user_emails::Column::IsActivated.eq(true))
200 .filter(user_emails::Column::DeletedAt.is_null())
201 .all(self.db().read())
202 .await?;
203
204 let map = rows.into_iter().map(|r| (r.user_id, r.lower_email)).collect();
205 Ok(map)
206 }
207
208 pub(crate) async fn send_email(
209 &self,
210 to: &str,
211 subject: &str,
212 text: &str,
213 html: &str,
214 ) -> Result<(), IssueError> {
215 let builder = Email::builder(subject).to([to]).text_body(text);
216 let builder = if html.is_empty() {
217 builder
218 } else {
219 builder.html_body(html)
220 };
221 let email = builder
222 .build()
223 .map_err(|error| IssueError::Internal(format!("mail build failed: {error}")))?;
224 self.state
225 .email
226 .send(&email)
227 .await
228 .map(|_| ())
229 .map_err(IssueError::from)
230 }
231}
Added lib/issues/src/services/stopwatch.rs lib/issues/src/services/stopwatch.rs +154 −0 View lib/issues/src/services/stopwatch.rs
@@ -0,0 +1,154 @@
1use models::issue::stopwatch;
2use models::issue::tracked_time;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait,
5};
6use serde::{Deserialize, Serialize};
7use space::services::spaces::UserBriefDto;
8use time::OffsetDateTime;
9use utoipa::{IntoParams, ToSchema};
10use uuid::Uuid;
11
12use crate::errors::IssueError;
13use crate::services::service::IssueService;
14use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
15
16#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
17pub struct StopwatchDto {
18 pub id: Uuid,
19 pub issue_id: Uuid,
20 pub user: Option<UserBriefDto>,
21 pub started_at: OffsetDateTime,
22 pub created_at: OffsetDateTime,
23}
24
25#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
26pub struct StopwatchPath {
27 pub space_name: String,
28 pub board_id: Uuid,
29 pub issue_id: Uuid,
30}
31
32impl IssueService {
33 pub async fn get_stopwatch(
34 &self,
35 caller: Uuid,
36 space_name: &str,
37 board_id: Uuid,
38 issue_id: Uuid,
39 ) -> Result<StopwatchDto, IssueError> {
40 let space = lookup_space_by_name(self, space_name).await?;
41 require_member(self, &space, caller).await?;
42 let board = lookup_board(self, &space, board_id).await?;
43 let _ = lookup_issue(self, &board, issue_id).await?;
44
45 let sw = stopwatch::Entity::find()
46 .filter(stopwatch::Column::IssueId.eq(issue_id))
47 .filter(stopwatch::Column::UserId.eq(caller))
48 .one(self.db().read())
49 .await?
50 .ok_or(IssueError::NotFound)?;
51
52 let user_map = self.load_user_briefs(&[sw.user_id]).await?;
53 let user = user_map.get(&sw.user_id).cloned();
54
55 Ok(StopwatchDto {
56 id: sw.id,
57 issue_id: sw.issue_id,
58 user,
59 started_at: sw.started_at,
60 created_at: sw.created_at,
61 })
62 }
63
64 pub async fn start_stopwatch(
65 &self,
66 caller: Uuid,
67 space_name: &str,
68 board_id: Uuid,
69 issue_id: Uuid,
70 ) -> Result<StopwatchDto, IssueError> {
71 let space = lookup_space_by_name(self, space_name).await?;
72 require_member(self, &space, caller).await?;
73 let board = lookup_board(self, &space, board_id).await?;
74 let _ = lookup_issue(self, &board, issue_id).await?;
75
76 let existing = stopwatch::Entity::find()
77 .filter(stopwatch::Column::IssueId.eq(issue_id))
78 .filter(stopwatch::Column::UserId.eq(caller))
79 .one(self.db().read())
80 .await?;
81
82 if existing.is_some() {
83 return Err(IssueError::Conflict(
84 "stopwatch already running for this issue".to_string(),
85 ));
86 }
87
88 let now = OffsetDateTime::now_utc();
89 let row = stopwatch::ActiveModel {
90 id: Set(Uuid::now_v7()),
91 issue_id: Set(issue_id),
92 user_id: Set(caller),
93 started_at: Set(now),
94 created_at: Set(now),
95 };
96
97 let inserted = row.insert(self.db().write()).await?;
98 let user_map = self.load_user_briefs(&[caller]).await?;
99 let user = user_map.get(&caller).cloned();
100
101 Ok(StopwatchDto {
102 id: inserted.id,
103 issue_id: inserted.issue_id,
104 user,
105 started_at: inserted.started_at,
106 created_at: inserted.created_at,
107 })
108 }
109
110 pub async fn stop_stopwatch(
111 &self,
112 caller: Uuid,
113 space_name: &str,
114 board_id: Uuid,
115 issue_id: Uuid,
116 ) -> Result<(), IssueError> {
117 let space = lookup_space_by_name(self, space_name).await?;
118 require_member(self, &space, caller).await?;
119 let board = lookup_board(self, &space, board_id).await?;
120 let _ = lookup_issue(self, &board, issue_id).await?;
121
122 let sw = stopwatch::Entity::find()
123 .filter(stopwatch::Column::IssueId.eq(issue_id))
124 .filter(stopwatch::Column::UserId.eq(caller))
125 .one(self.db().read())
126 .await?
127 .ok_or(IssueError::NotFound)?;
128
129 let now = OffsetDateTime::now_utc();
130 let duration = (now - sw.started_at).whole_seconds().max(0);
131
132 self.db()
133 .primary()
134 .transaction::<_, (), IssueError>(|txn| {
135 Box::pin(async move {
136 stopwatch::Entity::delete_by_id(sw.id).exec(txn).await?;
137
138 let tt = tracked_time::ActiveModel {
139 id: Set(Uuid::now_v7()),
140 issue_id: Set(issue_id),
141 user_id: Set(caller),
142 seconds: Set(duration),
143 created_at: Set(now),
144 };
145 tt.insert(txn).await?;
146
147 Ok(())
148 })
149 })
150 .await?;
151
152 Ok(())
153 }
154}
Added lib/issues/src/services/tracked_time.rs lib/issues/src/services/tracked_time.rs +98 −0 View lib/issues/src/services/tracked_time.rs
@@ -0,0 +1,98 @@
1use models::issue::tracked_time;
2use sea_orm::{
3 ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect,
4};
5use serde::{Deserialize, Serialize};
6use space::services::spaces::UserBriefDto;
7use time::OffsetDateTime;
8use utoipa::{IntoParams, ToSchema};
9use uuid::Uuid;
10
11use crate::errors::IssueError;
12use crate::services::service::IssueService;
13use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
14
15#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
16pub struct TrackedTimeDto {
17 pub id: Uuid,
18 pub issue_id: Uuid,
19 pub user: Option<UserBriefDto>,
20 pub seconds: i64,
21 pub created_at: OffsetDateTime,
22}
23
24#[derive(Debug, Clone, Serialize, ToSchema)]
25pub struct TrackedTimePage {
26 pub items: Vec<TrackedTimeDto>,
27 pub total: i64,
28}
29
30#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
31pub struct ListTrackedTimeQuery {
32 pub user_id: Option<Uuid>,
33 pub limit: Option<u64>,
34 pub offset: Option<u64>,
35}
36
37#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
38pub struct TrackedTimePath {
39 pub space_name: String,
40 pub board_id: Uuid,
41 pub issue_id: Uuid,
42}
43
44impl IssueService {
45 pub async fn list_tracked_time(
46 &self,
47 caller: Uuid,
48 space_name: &str,
49 board_id: Uuid,
50 issue_id: Uuid,
51 q: ListTrackedTimeQuery,
52 ) -> Result<TrackedTimePage, IssueError> {
53 let space = lookup_space_by_name(self, space_name).await?;
54 require_member(self, &space, caller).await?;
55 let board = lookup_board(self, &space, board_id).await?;
56 let _ = lookup_issue(self, &board, issue_id).await?;
57
58 let max_pagination = self.config().issues.max_pagination;
59 let default_pagination = self.config().issues.default_pagination;
60 let limit = q.limit.unwrap_or(default_pagination).clamp(1, max_pagination);
61 let offset = q.offset.unwrap_or(0);
62
63 let mut select = tracked_time::Entity::find()
64 .filter(tracked_time::Column::IssueId.eq(issue_id));
65
66 if let Some(uid) = q.user_id {
67 select = select.filter(tracked_time::Column::UserId.eq(uid));
68 }
69
70 select = select.order_by_desc(tracked_time::Column::CreatedAt);
71
72 let total = select.clone().count(self.db().read()).await? as i64;
73 let rows = select
74 .limit(limit)
75 .offset(offset)
76 .all(self.db().read())
77 .await?;
78
79 let user_ids: Vec<Uuid> = rows.iter().map(|t| t.user_id).collect();
80 let user_map = self.load_user_briefs(&user_ids).await?;
81
82 let items = rows
83 .into_iter()
84 .map(|t| {
85 let user = user_map.get(&t.user_id).cloned();
86 TrackedTimeDto {
87 id: t.id,
88 issue_id: t.issue_id,
89 user,
90 seconds: t.seconds,
91 created_at: t.created_at,
92 }
93 })
94 .collect();
95
96 Ok(TrackedTimePage { items, total })
97 }
98}
Added lib/issues/src/services/watches.rs lib/issues/src/services/watches.rs +148 −0 View lib/issues/src/services/watches.rs
@@ -0,0 +1,148 @@
1use models::common::watch_mode::WatchMode;
2use models::issue::issue_watch;
3use sea_orm::{
4 ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set,
5};
6use serde::{Deserialize, Serialize};
7use space::services::spaces::UserBriefDto;
8use time::OffsetDateTime;
9use utoipa::{IntoParams, ToSchema};
10use uuid::Uuid;
11
12use crate::errors::IssueError;
13use crate::services::service::IssueService;
14use crate::share::lookup::{lookup_board, lookup_issue, lookup_space_by_name, require_member};
15
16#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
17pub struct WatchDto {
18 pub id: Uuid,
19 pub issue_id: Option<Uuid>,
20 pub user: Option<UserBriefDto>,
21 pub mode: WatchMode,
22 pub created_at: OffsetDateTime,
23 pub updated_at: Option<OffsetDateTime>,
24}
25
26#[derive(Debug, Clone, Deserialize, ToSchema)]
27pub struct UpsertWatchCmd {
28 pub mode: WatchMode,
29}
30
31#[derive(Debug, Clone, Deserialize, IntoParams, ToSchema)]
32pub struct WatchPath {
33 pub space_name: String,
34 pub board_id: Uuid,
35 pub issue_id: Uuid,
36}
37
38impl IssueService {
39 pub async fn get_watch(
40 &self,
41 caller: Uuid,
42 space_name: &str,
43 board_id: Uuid,
44 issue_id: Uuid,
45 ) -> Result<WatchDto, IssueError> {
46 let space = lookup_space_by_name(self, space_name).await?;
47 require_member(self, &space, caller).await?;
48 let board = lookup_board(self, &space, board_id).await?;
49 let _ = lookup_issue(self, &board, issue_id).await?;
50
51 let w = issue_watch::Entity::find()
52 .filter(issue_watch::Column::IssueId.eq(Some(issue_id)))
53 .filter(issue_watch::Column::UserId.eq(caller))
54 .one(self.db().read())
55 .await?
56 .ok_or(IssueError::NotFound)?;
57
58 let user_map = self.load_user_briefs(&[w.user_id]).await?;
59 let user = user_map.get(&w.user_id).cloned();
60
61 Ok(WatchDto {
62 id: w.id,
63 issue_id: w.issue_id,
64 user,
65 mode: w.mode,
66 created_at: w.created_at,
67 updated_at: w.updated_at,
68 })
69 }
70
71 pub async fn upsert_watch(
72 &self,
73 caller: Uuid,
74 space_name: &str,
75 board_id: Uuid,
76 issue_id: Uuid,
77 cmd: UpsertWatchCmd,
78 ) -> Result<WatchDto, IssueError> {
79 let space = lookup_space_by_name(self, space_name).await?;
80 require_member(self, &space, caller).await?;
81 let board = lookup_board(self, &space, board_id).await?;
82 let _ = lookup_issue(self, &board, issue_id).await?;
83
84 let existing = issue_watch::Entity::find()
85 .filter(issue_watch::Column::IssueId.eq(Some(issue_id)))
86 .filter(issue_watch::Column::UserId.eq(caller))
87 .one(self.db().read())
88 .await?;
89
90 let now = OffsetDateTime::now_utc();
91 let (id, created_at, updated_at) = if let Some(w) = existing {
92 let mut am: issue_watch::ActiveModel = w.into();
93 am.mode = Set(cmd.mode);
94 am.updated_at = Set(Some(now));
95 let u = am.update(self.db().write()).await?;
96 (u.id, u.created_at, u.updated_at)
97 } else {
98 let row = issue_watch::ActiveModel {
99 id: Set(Uuid::now_v7()),
100 issue_id: Set(Some(issue_id)),
101 project_id: Set(None),
102 user_id: Set(caller),
103 mode: Set(cmd.mode),
104 created_at: Set(now),
105 updated_at: Set(None),
106 };
107 let ins = row.insert(self.db().write()).await?;
108 (ins.id, ins.created_at, ins.updated_at)
109 };
110
111 let user_map = self.load_user_briefs(&[caller]).await?;
112 let user = user_map.get(&caller).cloned();
113
114 Ok(WatchDto {
115 id,
116 issue_id: Some(issue_id),
117 user,
118 mode: cmd.mode,
119 created_at,
120 updated_at,
121 })
122 }
123
124 pub async fn delete_watch(
125 &self,
126 caller: Uuid,
127 space_name: &str,
128 board_id: Uuid,
129 issue_id: Uuid,
130 ) -> Result<(), IssueError> {
131 let space = lookup_space_by_name(self, space_name).await?;
132 require_member(self, &space, caller).await?;
133 let board = lookup_board(self, &space, board_id).await?;
134 let _ = lookup_issue(self, &board, issue_id).await?;
135
136 let res = issue_watch::Entity::delete_many()
137 .filter(issue_watch::Column::IssueId.eq(Some(issue_id)))
138 .filter(issue_watch::Column::UserId.eq(caller))
139 .exec(self.db().write())
140 .await?;
141
142 if res.rows_affected == 0 {
143 return Err(IssueError::NotFound);
144 }
145
146 Ok(())
147 }
148}
Added lib/issues/src/share/authz.rs lib/issues/src/share/authz.rs +21 −0 View lib/issues/src/share/authz.rs
@@ -0,0 +1,21 @@
1use models::space::spaces;
2use space::share::authz::{effective_permissions, MANAGE_CHANNELS};
3use uuid::Uuid;
4
5use crate::errors::IssueError;
6use crate::services::service::IssueService;
7
8pub async fn require_board_manage(
9 service: &IssueService,
10 space: &spaces::Model,
11 caller: Uuid,
12) -> Result<(), IssueError> {
13 let perms = effective_permissions(service.db().read(), space, caller)
14 .await
15 .map_err(IssueError::from)?;
16 if space.owner_id == caller || perms.has(MANAGE_CHANNELS) {
17 Ok(())
18 } else {
19 Err(IssueError::Forbidden)
20 }
21}
Added lib/issues/src/share/guard.rs lib/issues/src/share/guard.rs +45 −0 View lib/issues/src/share/guard.rs
@@ -0,0 +1,45 @@
1use axum::extract::{Request, State};
2use axum::middleware::Next;
3use axum::response::{IntoResponse, Response};
4use models::users::user;
5use models::users::user_sessions;
6use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
7use session::{Session, SessionUser, SessionUserMut};
8use time::OffsetDateTime;
9
10use crate::errors::{ApiError, IssueError};
11use crate::services::service::IssueService;
12use crate::share::session::{pwd_version_matches, session_token_key};
13
14pub async fn issue_guard(
15 State(service): State<IssueService>,
16 session: Session,
17 request: Request,
18 next: Next,
19) -> Response {
20 let Some(uid) = session.uid() else {
21 return ApiError::from(IssueError::Unauthorized).into_response();
22 };
23 let Some(token_hash) = session.get::<String>(&session_token_key(uid)) else {
24 return ApiError::from(IssueError::Unauthorized).into_response();
25 };
26 let outcome = user_sessions::Entity::find()
27 .filter(user_sessions::Column::TokenHash.eq(&token_hash))
28 .find_also_related(user::Entity)
29 .one(service.db().read())
30 .await;
31 let authorized = match outcome {
32 Ok(Some((row, Some(current)))) => {
33 !row.is_revoked
34 && row.expires_at > OffsetDateTime::now_utc()
35 && pwd_version_matches(&session, current.passwd_version)
36 }
37 Ok(_) => false,
38 Err(error) => return ApiError::from(IssueError::from(error)).into_response(),
39 };
40 if !authorized {
41 session.logout_user(uid);
42 return ApiError::from(IssueError::Unauthorized).into_response();
43 }
44 next.run(request).await
45}
Added lib/issues/src/share/lookup.rs lib/issues/src/share/lookup.rs +72 −0 View lib/issues/src/share/lookup.rs
@@ -0,0 +1,72 @@
1use models::dir::dirs::{self, DirKind};
2use models::issue::issue_item;
3use models::issue::issues;
4use models::space::spaces;
5use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
6use space::share::authz::effective_permissions;
7use uuid::Uuid;
8
9use crate::errors::IssueError;
10use crate::services::service::IssueService;
11
12pub async fn lookup_space_by_name(
13 service: &IssueService,
14 space_name: &str,
15) -> Result<spaces::Model, IssueError> {
16 let lower = space_name.trim().to_lowercase();
17 if lower.is_empty() {
18 return Err(IssueError::NotFound);
19 }
20 spaces::Entity::find()
21 .filter(spaces::Column::LowerName.eq(&lower))
22 .filter(spaces::Column::IsActive.eq(true))
23 .one(service.db().read())
24 .await?
25 .ok_or(IssueError::NotFound)
26}
27
28pub async fn lookup_board(
29 service: &IssueService,
30 space: &spaces::Model,
31 board_id: Uuid,
32) -> Result<issues::Model, IssueError> {
33 let board = issues::Entity::find_by_id(board_id)
34 .one(service.db().read())
35 .await?
36 .ok_or(IssueError::NotFound)?;
37
38 let dir = dirs::Entity::find_by_id(board.dir_id)
39 .filter(dirs::Column::SpaceId.eq(Some(space.id)))
40 .one(service.db().read())
41 .await?
42 .ok_or(IssueError::NotFound)?;
43
44 if dir.kind != DirKind::Dir {
45 return Err(IssueError::NotFound);
46 }
47
48 Ok(board)
49}
50
51pub async fn lookup_issue(
52 service: &IssueService,
53 board: &issues::Model,
54 issue_id: Uuid,
55) -> Result<issue_item::Model, IssueError> {
56 issue_item::Entity::find_by_id(issue_id)
57 .filter(issue_item::Column::CollectionId.eq(board.id))
58 .one(service.db().read())
59 .await?
60 .ok_or(IssueError::NotFound)
61}
62
63pub async fn require_member(
64 service: &IssueService,
65 space: &spaces::Model,
66 caller: Uuid,
67) -> Result<(), IssueError> {
68 effective_permissions(service.db().read(), space, caller)
69 .await
70 .map_err(IssueError::from)?;
71 Ok(())
72}
Added lib/issues/src/share/mod.rs lib/issues/src/share/mod.rs +4 −0 View lib/issues/src/share/mod.rs
@@ -0,0 +1,4 @@
1pub mod authz;
2pub mod guard;
3pub mod lookup;
4pub mod session;
Added lib/issues/src/share/session.rs lib/issues/src/share/session.rs +25 −0 View lib/issues/src/share/session.rs
@@ -0,0 +1,25 @@
1use session::{Session, SessionUser};
2use uuid::Uuid;
3
4use crate::errors::{ApiError, IssueError};
5
6pub const PWD_VERSION_KEY: &str = "auth:pwd_version";
7
8pub fn session_token_key(uid: Uuid) -> String {
9 format!("auth:token_hash:{uid}")
10}
11
12pub fn require_uid(session: &Session) -> Result<Uuid, Box<ApiError>> {
13 session
14 .uid()
15 .ok_or_else(|| Box::new(ApiError::from(IssueError::Unauthorized)))
16}
17
18pub fn pwd_version_matches(session: &Session, current: Option<i64>) -> bool {
19 let session_ver: Option<i64> = session.get(PWD_VERSION_KEY);
20 match (session_ver, current) {
21 (None, None) => true,
22 (Some(s), Some(c)) => s == c,
23 _ => false,
24 }
25}
Added lib/issues/src/errors.rs lib/issues/src/errors.rs +182 −0 View lib/issues/src/errors.rs
@@ -0,0 +1,182 @@
1use std::fmt;
2
3use axum::extract::rejection::{PathRejection, QueryRejection};
4use axum::http::StatusCode;
5use axum::response::{IntoResponse, Response};
6use axum::Json;
7use export::rest::{AppResponseData, NoData};
8use utoipa::IntoResponses;
9
10#[derive(Debug)]
11pub enum IssueError {
12 BadRequest(String),
13 Unauthorized,
14 Forbidden,
15 NotFound,
16 Conflict(String),
17 Internal(String),
18}
19
20impl fmt::Display for IssueError {
21 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
22 match self {
23 Self::BadRequest(message) => write!(f, "{message}"),
24 Self::Unauthorized => write!(f, "unauthorized"),
25 Self::Forbidden => write!(f, "forbidden"),
26 Self::NotFound => write!(f, "not found"),
27 Self::Conflict(message) => write!(f, "{message}"),
28 Self::Internal(message) => write!(f, "{message}"),
29 }
30 }
31}
32
33impl std::error::Error for IssueError {}
34
35impl From<sea_orm::DbErr> for IssueError {
36 fn from(error: sea_orm::DbErr) -> Self {
37 if is_unique_violation(&error) {
38 return Self::Conflict("resource already exists".to_string());
39 }
40 Self::Internal(format!("database error: {error}"))
41 }
42}
43
44impl From<sea_orm::TransactionError<sea_orm::DbErr>> for IssueError {
45 fn from(error: sea_orm::TransactionError<sea_orm::DbErr>) -> Self {
46 match &error {
47 sea_orm::TransactionError::Transaction(db_err) if is_unique_violation(db_err) => {
48 Self::Conflict("resource already exists".to_string())
49 }
50 _ => Self::Internal(format!("database transaction error: {error}")),
51 }
52 }
53}
54
55impl From<sea_orm::TransactionError<IssueError>> for IssueError {
56 fn from(error: sea_orm::TransactionError<IssueError>) -> Self {
57 match error {
58 sea_orm::TransactionError::Connection(db_err) => Self::from(db_err),
59 sea_orm::TransactionError::Transaction(issue_err) => issue_err,
60 }
61 }
62}
63
64fn is_unique_violation(err: &sea_orm::DbErr) -> bool {
65 let msg = err.to_string().to_lowercase();
66 msg.contains("unique") || msg.contains("duplicate") || msg.contains("23505")
67}
68
69impl From<space::errors::SpaceError> for IssueError {
70 fn from(error: space::errors::SpaceError) -> Self {
71 match error {
72 space::errors::SpaceError::BadRequest(msg) => Self::BadRequest(msg),
73 space::errors::SpaceError::Unauthorized => Self::Unauthorized,
74 space::errors::SpaceError::Forbidden => Self::Forbidden,
75 space::errors::SpaceError::NotFound => Self::NotFound,
76 space::errors::SpaceError::Conflict(msg) => Self::Conflict(msg),
77 space::errors::SpaceError::Internal(msg) => Self::Internal(msg),
78 }
79 }
80}
81
82impl From<export::ExportError> for IssueError {
83 fn from(error: export::ExportError) -> Self {
84 Self::Internal(format!("infrastructure error: {error}"))
85 }
86}
87
88impl From<serde_json::Error> for IssueError {
89 fn from(error: serde_json::Error) -> Self {
90 Self::BadRequest(format!("invalid payload: {error}"))
91 }
92}
93
94#[derive(Debug, IntoResponses)]
95pub enum ApiError {
96 #[response(status = 400, description = "request failed validation")]
97 BadRequest(#[to_schema] AppResponseData<NoData>),
98 #[response(status = 401, description = "authentication required")]
99 Unauthorized(#[to_schema] AppResponseData<NoData>),
100 #[response(status = 403, description = "operation not allowed")]
101 Forbidden(#[to_schema] AppResponseData<NoData>),
102 #[response(status = 404, description = "resource not found")]
103 NotFound(#[to_schema] AppResponseData<NoData>),
104 #[response(status = 409, description = "resource conflict")]
105 Conflict(#[to_schema] AppResponseData<NoData>),
106 #[response(status = 500, description = "internal server error")]
107 Internal(#[to_schema] AppResponseData<NoData>),
108}
109
110impl ApiError {
111 fn payload(code: i32, message: &'static str) -> AppResponseData<NoData> {
112 AppResponseData::err(code, message)
113 }
114
115 fn decompose(self) -> (u16, AppResponseData<NoData>) {
116 match self {
117 Self::BadRequest(envelope) => (400, envelope),
118 Self::Unauthorized(envelope) => (401, envelope),
119 Self::Forbidden(envelope) => (403, envelope),
120 Self::NotFound(envelope) => (404, envelope),
121 Self::Conflict(envelope) => (409, envelope),
122 Self::Internal(envelope) => (500, envelope),
123 }
124 }
125}
126
127impl IntoResponse for ApiError {
128 fn into_response(self) -> Response {
129 let (code, envelope) = self.decompose();
130 let status = match StatusCode::from_u16(code) {
131 Ok(status) => status,
132 Err(_) => StatusCode::INTERNAL_SERVER_ERROR,
133 };
134 (status, Json(envelope)).into_response()
135 }
136}
137
138impl From<IssueError> for ApiError {
139 fn from(error: IssueError) -> Self {
140 match error {
141 IssueError::BadRequest(msg) => Self::BadRequest(AppResponseData::err(400, msg)),
142 IssueError::Unauthorized => Self::Unauthorized(Self::payload(401, "unauthorized")),
143 IssueError::Forbidden => Self::Forbidden(Self::payload(403, "forbidden")),
144 IssueError::NotFound => Self::NotFound(Self::payload(404, "not found")),
145 IssueError::Conflict(msg) => Self::Conflict(AppResponseData::err(409, msg)),
146 IssueError::Internal(message) => {
147 tracing::warn!(error = %message, "issues handler failed");
148 Self::Internal(Self::payload(500, "internal server error"))
149 }
150 }
151 }
152}
153
154impl From<Box<ApiError>> for ApiError {
155 fn from(error: Box<ApiError>) -> Self {
156 *error
157 }
158}
159
160impl From<AppResponseData<()>> for ApiError {
161 fn from(_error: AppResponseData<()>) -> Self {
162 Self::BadRequest(Self::payload(400, "bad request"))
163 }
164}
165
166impl From<PathRejection> for ApiError {
167 fn from(_error: PathRejection) -> Self {
168 Self::BadRequest(Self::payload(400, "bad request"))
169 }
170}
171
172impl From<QueryRejection> for ApiError {
173 fn from(_error: QueryRejection) -> Self {
174 Self::BadRequest(Self::payload(400, "bad request"))
175 }
176}
177
178impl From<(StatusCode, &'static str)> for ApiError {
179 fn from(_error: (StatusCode, &'static str)) -> Self {
180 Self::Internal(Self::payload(500, "internal server error"))
181 }
182}
Added lib/issues/src/lib.rs lib/issues/src/lib.rs +6 −0 View lib/issues/src/lib.rs
@@ -0,0 +1,6 @@
1pub mod errors;
2pub mod handlers;
3pub mod openapi;
4pub mod routes;
5pub mod services;
6pub mod share;
Added lib/issues/src/openapi.rs lib/issues/src/openapi.rs +181 −0 View lib/issues/src/openapi.rs
@@ -0,0 +1,181 @@
1use export::rest::AppResponseData;
2use utoipa::OpenApi;
3
4#[derive(OpenApi)]
5#[openapi(
6 paths(
7 crate::handlers::boards::create_board::create_board,
8 crate::handlers::boards::list_boards::list_boards,
9 crate::handlers::boards::get_board::get_board,
10 crate::handlers::boards::update_board::update_board,
11 crate::handlers::boards::delete_board::delete_board,
12 crate::handlers::issues::list_issues::list_issues,
13 crate::handlers::issues::create_issue::create_issue,
14 crate::handlers::issues::get_issue::get_issue,
15 crate::handlers::issues::update_issue::update_issue,
16 crate::handlers::issues::delete_issue::delete_issue,
17 crate::handlers::issues::close_issue::close_issue,
18 crate::handlers::issues::reopen_issue::reopen_issue,
19 crate::handlers::comments::list_comments::list_comments,
20 crate::handlers::comments::create_comment::create_comment,
21 crate::handlers::comments::get_comment::get_comment,
22 crate::handlers::comments::update_comment::update_comment,
23 crate::handlers::comments::delete_comment::delete_comment,
24 crate::handlers::labels::list_labels::list_labels,
25 crate::handlers::labels::create_label::create_label,
26 crate::handlers::labels::get_label::get_label,
27 crate::handlers::labels::update_label::update_label,
28 crate::handlers::labels::delete_label::delete_label,
29 crate::handlers::labels::list_labels_for_issue::list_labels_for_issue,
30 crate::handlers::labels::bind_label::bind_label,
31 crate::handlers::labels::unbind_label::unbind_label,
32 crate::handlers::milestones::list_milestones::list_milestones,
33 crate::handlers::milestones::create_milestone::create_milestone,
34 crate::handlers::milestones::get_milestone::get_milestone,
35 crate::handlers::milestones::update_milestone::update_milestone,
36 crate::handlers::milestones::delete_milestone::delete_milestone,
37 crate::handlers::reactions::create_reaction::create_reaction,
38 crate::handlers::reactions::list_reactions::list_reactions,
39 crate::handlers::reactions::create_comment_reaction::create_comment_reaction,
40 crate::handlers::reactions::list_comment_reactions::list_comment_reactions,
41 crate::handlers::reactions::delete_reaction::delete_reaction,
42 crate::handlers::watches::get_watch::get_watch,
43 crate::handlers::watches::upsert_watch::upsert_watch,
44 crate::handlers::watches::delete_watch::delete_watch,
45 crate::handlers::members::list_members::list_members,
46 crate::handlers::members::create_member::create_member,
47 crate::handlers::members::delete_member::delete_member,
48 crate::handlers::pins::list_pins::list_pins,
49 crate::handlers::pins::create_pin::create_pin,
50 crate::handlers::pins::delete_pin::delete_pin,
51 crate::handlers::events::list_events::list_events,
52 crate::handlers::dependencies::list_dependencies::list_dependencies,
53 crate::handlers::dependencies::create_dependency::create_dependency,
54 crate::handlers::dependencies::delete_dependency::delete_dependency,
55 crate::handlers::content_history::list_content_history::list_content_history,
56 crate::handlers::stopwatch::get_stopwatch::get_stopwatch,
57 crate::handlers::stopwatch::start_stopwatch::start_stopwatch,
58 crate::handlers::stopwatch::stop_stopwatch::stop_stopwatch,
59 crate::handlers::tracked_time::list_tracked_time::list_tracked_time,
60 crate::handlers::issue_users::list_issue_users::list_issue_users,
61 crate::handlers::issue_users::update_issue_user::update_issue_user,
62 crate::handlers::repo_relations::list_repo_relations::list_repo_relations,
63 crate::handlers::repo_relations::create_repo_relation::create_repo_relation,
64 crate::handlers::repo_relations::delete_repo_relation::delete_repo_relation,
65 ),
66 components(schemas(
67 AppResponseData<export::rest::NoData>,
68 AppResponseData<crate::services::boards::BoardDto>,
69 AppResponseData<crate::services::boards::BoardPage>,
70 AppResponseData<crate::services::issues::IssueDto>,
71 AppResponseData<crate::services::issues::IssuePage>,
72 AppResponseData<crate::services::comments::CommentDto>,
73 AppResponseData<crate::services::comments::CommentPage>,
74 AppResponseData<crate::services::labels::LabelDto>,
75 AppResponseData<crate::services::labels::LabelPage>,
76 AppResponseData<Vec<crate::services::labels::LabelDto>>,
77 AppResponseData<crate::services::milestones::MilestoneDto>,
78 AppResponseData<crate::services::milestones::MilestonePage>,
79 AppResponseData<crate::services::reactions::ReactionDto>,
80 AppResponseData<Vec<crate::services::reactions::ReactionAggDto>>,
81 AppResponseData<crate::services::watches::WatchDto>,
82 AppResponseData<crate::services::members::IssueMemberDto>,
83 AppResponseData<crate::services::members::IssueMemberPage>,
84 AppResponseData<crate::services::pins::PinDto>,
85 AppResponseData<Vec<crate::services::pins::PinDto>>,
86 AppResponseData<crate::services::events::EventPage>,
87 AppResponseData<crate::services::dependencies::DependencyDto>,
88 AppResponseData<Vec<crate::services::dependencies::DependencyDto>>,
89 AppResponseData<crate::services::content_history::ContentHistoryPage>,
90 AppResponseData<Option<crate::services::stopwatch::StopwatchDto>>,
91 AppResponseData<crate::services::stopwatch::StopwatchDto>,
92 AppResponseData<crate::services::tracked_time::TrackedTimePage>,
93 AppResponseData<crate::services::issue_users::IssueUserDto>,
94 AppResponseData<crate::services::issue_users::IssueUserPage>,
95 AppResponseData<crate::services::repo_relations::RepoRelDto>,
96 AppResponseData<Vec<crate::services::repo_relations::RepoRelDto>>,
97 export::rest::NoData,
98 space::services::spaces::UserBriefDto,
99 space::services::spaces::SpaceNamePath,
100 crate::services::boards::BoardDto,
101 crate::services::boards::BoardPage,
102 crate::services::boards::CreateBoardCmd,
103 crate::services::boards::UpdateBoardCmd,
104 crate::services::boards::ListBoardQuery,
105 crate::services::boards::BoardPath,
106 crate::services::issues::IssueDto,
107 crate::services::issues::IssuePage,
108 crate::services::issues::IssueState,
109 crate::services::issues::CreateIssueCmd,
110 crate::services::issues::UpdateIssueCmd,
111 crate::services::issues::ListIssueQuery,
112 crate::services::issues::IssuePath,
113 crate::services::comments::CommentDto,
114 crate::services::comments::CommentPage,
115 crate::services::comments::CreateCommentCmd,
116 crate::services::comments::UpdateCommentCmd,
117 crate::services::comments::ListCommentQuery,
118 crate::services::comments::CommentPath,
119 crate::services::labels::LabelDto,
120 crate::services::labels::LabelPage,
121 crate::services::labels::CreateLabelCmd,
122 crate::services::labels::UpdateLabelCmd,
123 crate::services::labels::ListLabelQuery,
124 crate::services::labels::LabelPath,
125 crate::services::labels::IssueLabelPath,
126 crate::services::milestones::MilestoneDto,
127 crate::services::milestones::MilestonePage,
128 crate::services::milestones::CreateMilestoneCmd,
129 crate::services::milestones::UpdateMilestoneCmd,
130 crate::services::milestones::ListMilestoneQuery,
131 crate::services::milestones::MilestonePath,
132 crate::services::reactions::ReactionDto,
133 crate::services::reactions::ReactionAggDto,
134 crate::services::reactions::ReactionScope,
135 crate::services::reactions::CreateReactionCmd,
136 crate::services::reactions::ReactionPath,
137 crate::services::reactions::CommentReactionPath,
138 crate::services::watches::WatchDto,
139 crate::services::watches::UpsertWatchCmd,
140 crate::services::watches::WatchPath,
141 models::common::watch_mode::WatchMode,
142 crate::services::members::IssueMemberDto,
143 crate::services::members::IssueMemberPage,
144 crate::services::members::CreateIssueMemberCmd,
145 crate::services::members::ListIssueMemberQuery,
146 crate::services::members::IssueMemberPath,
147 models::issue::issue_member::IssueMemberKind,
148 crate::services::pins::PinDto,
149 crate::services::pins::CreatePinCmd,
150 crate::services::pins::PinPath,
151 crate::services::events::EventDto,
152 crate::services::events::EventPage,
153 crate::services::events::EventPath,
154 crate::services::events::ListEventQuery,
155 crate::services::dependencies::DependencyDto,
156 crate::services::dependencies::CreateDependencyCmd,
157 crate::services::dependencies::DependencyPath,
158 crate::services::content_history::ContentHistoryDto,
159 crate::services::content_history::ContentHistoryPage,
160 crate::services::content_history::ContentHistoryPath,
161 crate::services::content_history::ListContentHistoryQuery,
162 crate::services::stopwatch::StopwatchDto,
163 crate::services::stopwatch::StopwatchPath,
164 crate::services::tracked_time::TrackedTimeDto,
165 crate::services::tracked_time::TrackedTimePage,
166 crate::services::tracked_time::TrackedTimePath,
167 crate::services::tracked_time::ListTrackedTimeQuery,
168 crate::services::issue_users::IssueUserDto,
169 crate::services::issue_users::IssueUserPage,
170 crate::services::issue_users::UpdateIssueUserCmd,
171 crate::services::issue_users::IssueUserPath,
172 crate::services::issue_users::ListIssueUserQuery,
173 crate::services::repo_relations::RepoRelDto,
174 crate::services::repo_relations::CreateRepoRelCmd,
175 crate::services::repo_relations::RepoRelPath,
176 )),
177 tags(
178 (name = "issues", description = "Issue management API")
179 )
180)]
181pub struct IssuesApiDoc;
Added lib/issues/src/routes.rs lib/issues/src/routes.rs +208 −0 View lib/issues/src/routes.rs
@@ -0,0 +1,208 @@
1use axum::routing::{delete, get, patch, post};
2use axum::Router;
3
4use crate::handlers::boards::create_board::create_board;
5use crate::handlers::boards::delete_board::delete_board;
6use crate::handlers::boards::get_board::get_board;
7use crate::handlers::boards::list_boards::list_boards;
8use crate::handlers::boards::update_board::update_board;
9use crate::handlers::comments::create_comment::create_comment;
10use crate::handlers::comments::delete_comment::delete_comment;
11use crate::handlers::comments::get_comment::get_comment;
12use crate::handlers::comments::list_comments::list_comments;
13use crate::handlers::comments::update_comment::update_comment;
14use crate::handlers::content_history::list_content_history::list_content_history;
15use crate::handlers::dependencies::create_dependency::create_dependency;
16use crate::handlers::dependencies::delete_dependency::delete_dependency;
17use crate::handlers::dependencies::list_dependencies::list_dependencies;
18use crate::handlers::events::list_events::list_events;
19use crate::handlers::issue_users::list_issue_users::list_issue_users;
20use crate::handlers::issue_users::update_issue_user::update_issue_user;
21use crate::handlers::issues::close_issue::close_issue;
22use crate::handlers::issues::create_issue::create_issue;
23use crate::handlers::issues::delete_issue::delete_issue;
24use crate::handlers::issues::get_issue::get_issue;
25use crate::handlers::issues::list_issues::list_issues;
26use crate::handlers::issues::reopen_issue::reopen_issue;
27use crate::handlers::issues::update_issue::update_issue;
28use crate::handlers::labels::bind_label::bind_label;
29use crate::handlers::labels::create_label::create_label;
30use crate::handlers::labels::delete_label::delete_label;
31use crate::handlers::labels::get_label::get_label;
32use crate::handlers::labels::list_labels::list_labels;
33use crate::handlers::labels::list_labels_for_issue::list_labels_for_issue;
34use crate::handlers::labels::unbind_label::unbind_label;
35use crate::handlers::labels::update_label::update_label;
36use crate::handlers::members::create_member::create_member;
37use crate::handlers::members::delete_member::delete_member;
38use crate::handlers::members::list_members::list_members;
39use crate::handlers::milestones::create_milestone::create_milestone;
40use crate::handlers::milestones::delete_milestone::delete_milestone;
41use crate::handlers::milestones::get_milestone::get_milestone;
42use crate::handlers::milestones::list_milestones::list_milestones;
43use crate::handlers::milestones::update_milestone::update_milestone;
44use crate::handlers::pins::create_pin::create_pin;
45use crate::handlers::pins::delete_pin::delete_pin;
46use crate::handlers::pins::list_pins::list_pins;
47use crate::handlers::reactions::create_comment_reaction::create_comment_reaction;
48use crate::handlers::reactions::create_reaction::create_reaction;
49use crate::handlers::reactions::delete_reaction::delete_reaction;
50use crate::handlers::reactions::list_comment_reactions::list_comment_reactions;
51use crate::handlers::reactions::list_reactions::list_reactions;
52use crate::handlers::repo_relations::create_repo_relation::create_repo_relation;
53use crate::handlers::repo_relations::delete_repo_relation::delete_repo_relation;
54use crate::handlers::repo_relations::list_repo_relations::list_repo_relations;
55use crate::handlers::stopwatch::get_stopwatch::get_stopwatch;
56use crate::handlers::stopwatch::start_stopwatch::start_stopwatch;
57use crate::handlers::stopwatch::stop_stopwatch::stop_stopwatch;
58use crate::handlers::tracked_time::list_tracked_time::list_tracked_time;
59use crate::handlers::watches::delete_watch::delete_watch;
60use crate::handlers::watches::get_watch::get_watch;
61use crate::handlers::watches::upsert_watch::upsert_watch;
62use crate::services::service::IssueService;
63use crate::share::guard::issue_guard;
64
65pub fn router(service: IssueService) -> Router<IssueService> {
66 Router::new()
67 .route("/issues/{space_name}", post(create_board).get(list_boards))
68 .route(
69 "/issues/{space_name}/{board_id}",
70 get(get_board).patch(update_board).delete(delete_board),
71 )
72 .route(
73 "/issues/{space_name}/{board_id}/issues",
74 get(list_issues).post(create_issue),
75 )
76 .route(
77 "/issues/{space_name}/{board_id}/{issue_id}",
78 get(get_issue).patch(update_issue).delete(delete_issue),
79 )
80 .route(
81 "/issues/{space_name}/{board_id}/{issue_id}/close",
82 post(close_issue),
83 )
84 .route(
85 "/issues/{space_name}/{board_id}/{issue_id}/reopen",
86 post(reopen_issue),
87 )
88 .route(
89 "/issues/{space_name}/{board_id}/{issue_id}/comments",
90 get(list_comments).post(create_comment),
91 )
92 .route(
93 "/issues/{space_name}/{board_id}/{issue_id}/comments/{comment_id}",
94 get(get_comment)
95 .patch(update_comment)
96 .delete(delete_comment),
97 )
98 .route(
99 "/issues/{space_name}/{board_id}/labels",
100 get(list_labels).post(create_label),
101 )
102 .route(
103 "/issues/{space_name}/{board_id}/labels/{label_id}",
104 get(get_label).patch(update_label).delete(delete_label),
105 )
106 .route(
107 "/issues/{space_name}/{board_id}/{issue_id}/labels",
108 get(list_labels_for_issue),
109 )
110 .route(
111 "/issues/{space_name}/{board_id}/{issue_id}/labels/{label_id}",
112 post(bind_label).delete(unbind_label),
113 )
114 .route(
115 "/issues/{space_name}/{board_id}/milestones",
116 get(list_milestones).post(create_milestone),
117 )
118 .route(
119 "/issues/{space_name}/{board_id}/milestones/{milestone_id}",
120 get(get_milestone)
121 .patch(update_milestone)
122 .delete(delete_milestone),
123 )
124 .route(
125 "/issues/{space_name}/{board_id}/{issue_id}/reactions",
126 get(list_reactions).post(create_reaction),
127 )
128 .route(
129 "/issues/{space_name}/{board_id}/{issue_id}/reactions/{reaction_id}",
130 delete(delete_reaction),
131 )
132 .route(
133 "/issues/{space_name}/{board_id}/{issue_id}/comments/{comment_id}/reactions",
134 get(list_comment_reactions).post(create_comment_reaction),
135 )
136 .route(
137 "/issues/{space_name}/{board_id}/{issue_id}/watch",
138 get(get_watch).put(upsert_watch).delete(delete_watch),
139 )
140 .route(
141 "/issues/{space_name}/{board_id}/{issue_id}/members",
142 get(list_members).post(create_member),
143 )
144 .route(
145 "/issues/{space_name}/{board_id}/{issue_id}/members/{member_id}",
146 delete(delete_member),
147 )
148 .route(
149 "/issues/{space_name}/{board_id}/pins",
150 get(list_pins).post(create_pin),
151 )
152 .route(
153 "/issues/{space_name}/{board_id}/pins/{pin_id}",
154 delete(delete_pin),
155 )
156 .route(
157 "/issues/{space_name}/{board_id}/{issue_id}/events",
158 get(list_events),
159 )
160 .route(
161 "/issues/{space_name}/{board_id}/{issue_id}/dependencies",
162 get(list_dependencies).post(create_dependency),
163 )
164 .route(
165 "/issues/{space_name}/{board_id}/{issue_id}/dependencies/{dep_id}",
166 delete(delete_dependency),
167 )
168 .route(
169 "/issues/{space_name}/{board_id}/{issue_id}/history",
170 get(list_content_history),
171 )
172 .route(
173 "/issues/{space_name}/{board_id}/{issue_id}/stopwatch",
174 get(get_stopwatch),
175 )
176 .route(
177 "/issues/{space_name}/{board_id}/{issue_id}/stopwatch/start",
178 post(start_stopwatch),
179 )
180 .route(
181 "/issues/{space_name}/{board_id}/{issue_id}/stopwatch/stop",
182 post(stop_stopwatch),
183 )
184 .route(
185 "/issues/{space_name}/{board_id}/{issue_id}/times",
186 get(list_tracked_time),
187 )
188 .route(
189 "/issues/{space_name}/{board_id}/{issue_id}/users",
190 get(list_issue_users),
191 )
192 .route(
193 "/issues/{space_name}/{board_id}/{issue_id}/users/{uid}",
194 patch(update_issue_user),
195 )
196 .route(
197 "/issues/{space_name}/{board_id}/{issue_id}/repos",
198 get(list_repo_relations).post(create_repo_relation),
199 )
200 .route(
201 "/issues/{space_name}/{board_id}/{issue_id}/repos/{rel_id}",
202 delete(delete_repo_relation),
203 )
204 .route_layer(axum::middleware::from_fn_with_state(
205 service.clone(),
206 issue_guard,
207 ))
208}