From ac44867ec180b5da25d9dcca2ddbb595aff9a1cd Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 22 Sep 2025 17:38:54 +0700 Subject: [PATCH] feat: Add member team management functionality with detailed DTOs and service methods --- imphnen-iam/src/v1/teams/teams_controller.rs | 126 +++++---- imphnen-iam/src/v1/teams/teams_dto.rs | 20 ++ imphnen-iam/src/v1/teams/teams_service.rs | 267 ++++++++++++++----- 3 files changed, 282 insertions(+), 131 deletions(-) diff --git a/imphnen-iam/src/v1/teams/teams_controller.rs b/imphnen-iam/src/v1/teams/teams_controller.rs index 7433973..f899c98 100644 --- a/imphnen-iam/src/v1/teams/teams_controller.rs +++ b/imphnen-iam/src/v1/teams/teams_controller.rs @@ -6,11 +6,13 @@ use crate::{ TeamMemberDto, TeamsSearchQueryDto, PublicTeamsListItemDto, PublicTeamsDetailItemDto, AdminTeamsListItemDto, AdminTeamsDetailItemDto, PermissionsEnum }; +use axum::http::StatusCode; +use axum::response::Response; +use axum::extract::Query; use axum::extract::Path; use axum::http::HeaderMap; use axum::response::IntoResponse; use axum::{Extension, Json}; - use super::teams_service::{TeamsServiceTrait, TeamsService}; #[utoipa::path( @@ -29,55 +31,60 @@ use super::teams_service::{TeamsServiceTrait, TeamsService}; ("filter_by" = Option, Query, description = "Field to filter by"), ), responses( - (status = 200, description = "Get team list", body = ResponseListSuccessDto>) + (status = 200, description = "Get team list", body = ResponseListSuccessDto>), + (status = 200, description = "Get public team list", body = ResponseListSuccessDto>) ), tag = "Teams" )] pub async fn get_team_list( - headers: HeaderMap, + headers: Option, Extension(state): Extension, axum::extract::Query(meta): axum::extract::Query, ) -> impl IntoResponse { - match permissions_guard( - headers, - Extension(state), - vec![], - ) - .await - { - Ok((_claims, state)) => TeamsService::get_team_list(&state, meta).await, - Err(response) => response, + match headers { + Some(headers) => { + match permissions_guard( + headers, + Extension(state.clone()), + vec![], + ).await { + Ok((_claims, state)) => TeamsService::get_team_list(&state, meta).await, + Err(_) => TeamsService::get_public_team_list(&state, meta).await, + } + }, + None => TeamsService::get_public_team_list(&state, meta).await, } } #[utoipa::path( -get, -security( - ("Bearer" = []) - ), -path = "/v1/teams/detail/{id}", -params( - ("id" = String, Path, description = "Team ID") -), -responses( - (status = 200, description = "Get team by ID", body = ResponseSuccessDto) -), -tag = "Teams" + get, + path = "/v1/teams/{id}", + params( + ("id" = String, Path, description = "Team ID") + ), + responses( + (status = 200, description = "Get team by ID", body = ResponseSuccessDto), + (status = 200, description = "Get public team by ID", body = ResponseSuccessDto) + ), + tag = "Teams" )] pub async fn get_team_by_id( - headers: HeaderMap, + headers: Option, Extension(state): Extension, Path(id): Path, ) -> impl IntoResponse { - match permissions_guard( - headers, - Extension(state), - vec![], - ) - .await - { - Ok((_claims, state)) => TeamsService::get_team_by_id(&state, id).await, - Err(response) => response, + match headers { + Some(headers) => { + match permissions_guard( + headers, + Extension(state.clone()), + vec![], + ).await { + Ok((_claims, state)) => TeamsService::get_team_by_id(&state, id).await, + Err(_) => TeamsService::get_public_team_by_id(&state, id).await, + } + }, + None => TeamsService::get_public_team_by_id(&state, id).await, } } @@ -303,7 +310,7 @@ pub async fn get_team_members( ("id" = String, Path, description = "Team ID") ), responses( - (status = 200, description = "Leave team", body = MessageResponseDto) + (status = 200, description = "Leave specific team", body = MessageResponseDto) ), tag = "Teams" )] @@ -325,45 +332,32 @@ pub async fn post_leave_team( } #[utoipa::path( - get, - path = "/v1/teams/public", - params( - ("page" = Option, Query, description = "Page number"), - ("per_page" = Option, Query, description = "Items per page"), - ("search" = Option, Query, description = "Search keyword"), - ("sort_by" = Option, Query, description = "Sort by field"), - ("order" = Option, Query, description = "Order ASC or DESC"), - ("filter" = Option, Query, description = "Filter value"), - ("filter_by" = Option, Query, description = "Field to filter by"), - ), + post, + security( + ("Bearer" = []) + ), + path = "/v1/teams/leave-me", responses( - (status = 200, description = "Get public team list", body = ResponseListSuccessDto>) + (status = 200, description = "Leave current team", body = MessageResponseDto) ), tag = "Teams" )] -pub async fn get_public_team_list( +pub async fn post_leave_current_team( + headers: HeaderMap, Extension(state): Extension, - axum::extract::Query(meta): axum::extract::Query, ) -> impl IntoResponse { - TeamsService::get_team_list(&state, meta).await + match permissions_guard( + headers, + Extension(state), + vec![], + ) + .await + { + Ok((claims, state)) => TeamsService::leave_current_team(&state, claims).await, + Err(response) => response, + } } -#[utoipa::path( - get, - path = "/v1/teams/public/{id}", - params( - ("id" = String, Path, description = "Team ID") - ), - responses( - (status = 200, description = "Get public team by ID", body = ResponseSuccessDto) - ), - tag = "Teams" -)] -pub async fn get_public_team_by_id( - Extension(state): Extension, - Path(id): Path, -) -> impl IntoResponse { - TeamsService::get_team_by_id(&state, id).await #[utoipa::path( get, security( diff --git a/imphnen-iam/src/v1/teams/teams_dto.rs b/imphnen-iam/src/v1/teams/teams_dto.rs index 14a2054..84366bc 100644 --- a/imphnen-iam/src/v1/teams/teams_dto.rs +++ b/imphnen-iam/src/v1/teams/teams_dto.rs @@ -110,6 +110,26 @@ pub struct TeamsDetailItemDto { pub updated_at: String, } +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct MemberTeamsDetailItemDto { + pub id: String, + pub name: String, + pub description: Option, + pub leader: TeamMemberDto, + pub is_open: bool, + pub max_members: Option, + pub current_member_count: i32, + pub skills_required: Option>, + pub location: Option, + pub avatar: Option, + pub website_url: Option, + pub github_url: Option, + pub members: Vec, // Always include members for authenticated users + pub is_active: bool, + pub created_at: String, + pub updated_at: String, +} + #[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] pub struct TeamsListItemDto { pub id: String, diff --git a/imphnen-iam/src/v1/teams/teams_service.rs b/imphnen-iam/src/v1/teams/teams_service.rs index 2c79705..414879e 100644 --- a/imphnen-iam/src/v1/teams/teams_service.rs +++ b/imphnen-iam/src/v1/teams/teams_service.rs @@ -1,6 +1,6 @@ use super::{ TeamsCreateRequestDto, TeamsUpdateRequestDto, TeamInviteRequestDto, - TeamAcceptInvitationRequestDto, TeamsDetailItemDto, + TeamAcceptInvitationRequestDto, TeamsDetailItemDto, MemberTeamsDetailItemDto, TeamMemberDto, TeamsRepository, TeamsSchema, TeamMembersSchema, TeamInvitationsSchema, TeamsSearchQueryDto, PublicTeamsListItemDto, PublicTeamsDetailItemDto, AdminTeamsListItemDto, AdminTeamsDetailItemDto @@ -24,6 +24,8 @@ use chrono::Utc; pub trait TeamsServiceTrait: Send + Sync + 'static { fn get_team_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>>; fn get_team_by_id(state: &AppState, id: String) -> Pin + Send>>; + fn get_member_team_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>>; + fn get_member_team_by_id(state: &AppState, id: String) -> Pin + Send>>; fn get_public_team_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>>; fn get_public_team_by_id(state: &AppState, id: String) -> Pin + Send>>; fn create_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, new_team: TeamsCreateRequestDto) -> Pin + Send>>; @@ -33,6 +35,7 @@ pub trait TeamsServiceTrait: Send + Sync + 'static { fn accept_invitation(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, accept: TeamAcceptInvitationRequestDto) -> Pin + Send>>; fn get_team_members(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin + Send>>; fn leave_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin + Send>>; + fn leave_current_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims) -> Pin + Send>>; fn search_teams(state: &AppState, search_params: TeamsSearchQueryDto) -> Pin + Send>>; fn get_admin_team_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>>; fn get_admin_team_by_id(state: &AppState, id: String) -> Pin + Send>>; @@ -107,69 +110,167 @@ impl TeamsService { impl TeamsServiceTrait for TeamsService { fn get_team_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>> { - let state = state.to_owned(); - Box::pin(async move { - let repo = TeamsRepository::new(&state); - match repo.query_team_list(meta).await { - Ok(data) => { - let response = ResponseListSuccessDto { - data: data.data, - meta: data.meta, - }; - success_list_response(response) + let state = state.to_owned(); + Box::pin(async move { + let repo = TeamsRepository::new(&state); + match repo.query_team_list(meta).await { + Ok(data) => { + let response = ResponseListSuccessDto { + data: data.data, + meta: data.meta, + }; + success_list_response(response) + } + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), } - Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), - } - }) - } - - fn get_team_by_id(state: &AppState, id: String) -> Pin + Send>> { - let state = state.to_owned(); - Box::pin(async move { - if Uuid::parse_str(&id).is_err() { - return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); - } - let repo = TeamsRepository::new(&state); - let thing_id = make_thing_from_enum(ResourceEnum::Teams, &id); - match repo.query_team_by_id(&thing_id).await { - Ok(team) if !team.is_deleted => { - let members = repo.query_team_members(&team.id).await.unwrap_or_default(); - - // For public team details, only show sensitive info if user is authenticated and part of the team - let team_dto = TeamsDetailItemDto { - id: team.id.id.to_raw(), - name: team.name, - description: team.description, - leader: TeamMemberDto { - id: String::new(), - user_id: team.leader_id.id.to_raw(), - fullname: String::new(), - email: None, - avatar: None, - role: "leader".to_string(), - skills: None, - joined_at: team.created_at.clone(), - }, - is_open: team.is_open, - max_members: team.max_members, - current_member_count: members.len() as i32 + 1, - skills_required: team.skills_required, - location: team.location, - avatar: team.avatar, - website_url: team.website_url, - github_url: team.github_url, - members: None, - is_active: team.is_active, - created_at: team.created_at, - updated_at: team.updated_at, - }; - success_response(ResponseSuccessDto { data: team_dto }) + }) + } + + fn get_team_by_id(state: &AppState, id: String) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + if Uuid::parse_str(&id).is_err() { + return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); } - Ok(_) => common_response(StatusCode::NOT_FOUND, "Team not found"), - Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), - } - }) - } + let repo = TeamsRepository::new(&state); + let thing_id = make_thing_from_enum(ResourceEnum::Teams, &id); + match repo.query_team_by_id(&thing_id).await { + Ok(team) if !team.is_deleted => { + let members = repo.query_team_members(&team.id).await.unwrap_or_default(); + + // For public team details, only show sensitive info if user is authenticated and part of the team + let team_dto = TeamsDetailItemDto { + id: team.id.id.to_raw(), + name: team.name, + description: team.description, + leader: TeamMemberDto { + id: String::new(), + user_id: team.leader_id.id.to_raw(), + fullname: String::new(), + email: None, + avatar: None, + role: "leader".to_string(), + skills: None, + joined_at: team.created_at.clone(), + }, + is_open: team.is_open, + max_members: team.max_members, + current_member_count: members.len() as i32 + 1, + skills_required: team.skills_required, + location: team.location, + avatar: team.avatar, + website_url: team.website_url, + github_url: team.github_url, + members: None, + is_active: team.is_active, + created_at: team.created_at, + updated_at: team.updated_at, + }; + success_response(ResponseSuccessDto { data: team_dto }) + } + Ok(_) => common_response(StatusCode::NOT_FOUND, "Team not found"), + Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), + } + }) + } + + fn get_member_team_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + let repo = TeamsRepository::new(&state); + match repo.query_team_list(meta).await { + Ok(data) => { + let response = ResponseListSuccessDto { + data: data.data, + meta: data.meta, + }; + success_list_response(response) + } + Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + } + }) + } + + fn get_member_team_by_id(state: &AppState, id: String) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + if Uuid::parse_str(&id).is_err() { + return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format"); + } + let repo = TeamsRepository::new(&state); + let thing_id = make_thing_from_enum(ResourceEnum::Teams, &id); + match repo.query_team_by_id(&thing_id).await { + Ok(team) if !team.is_deleted => { + let members = repo.query_team_members(&team.id).await.unwrap_or_default(); + + // For member team details, include all information including members list + let mut member_dtos = Vec::new(); + for member in members { + match Self::get_user_info_with_privacy( + &member.user_id.id.to_raw(), + "system", // In member context, we show all user info + true, // In member context, we show all user info + &state, + ).await { + Ok(mut member_dto) => { + member_dto.role = member.role; + member_dto.joined_at = member.joined_at; + member_dtos.push(member_dto); + } + Err(_) => continue, + } + } + + // Add leader with full info + let leader_dto = match Self::get_user_info_with_privacy( + &team.leader_id.id.to_raw(), + "system", + true, + &state, + ).await { + Ok(mut leader_dto) => { + leader_dto.role = "leader".to_string(); + leader_dto + } + Err(_) => TeamMemberDto { + id: String::new(), + user_id: team.leader_id.id.to_raw(), + fullname: String::new(), + email: None, + avatar: None, + role: "leader".to_string(), + skills: None, + joined_at: team.created_at.clone(), + } + }; + + member_dtos.insert(0, leader_dto); + + let team_dto = MemberTeamsDetailItemDto { + id: team.id.id.to_raw(), + name: team.name, + description: team.description, + leader: leader_dto, + is_open: team.is_open, + max_members: team.max_members, + current_member_count: members.len() as i32 + 1, + skills_required: team.skills_required, + location: team.location, + avatar: team.avatar, + website_url: team.website_url, + github_url: team.github_url, + members: member_dtos, + is_active: team.is_active, + created_at: team.created_at, + updated_at: team.updated_at, + }; + success_response(ResponseSuccessDto { data: team_dto }) + } + Ok(_) => common_response(StatusCode::NOT_FOUND, "Team not found"), + Err(e) => common_response(StatusCode::NOT_FOUND, &e.to_string()), + } + }) + } fn get_public_team_list(state: &AppState, meta: MetaRequestDto) -> Pin + Send>> { let state = state.to_owned(); @@ -761,8 +862,44 @@ impl TeamsServiceTrait for TeamsService { success_response(ResponseSuccessDto { data: member_dtos }) }) } - Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()), + + fn leave_current_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims) -> Pin + Send>> { + let state = state.to_owned(); + Box::pin(async move { + let repo = TeamsRepository::new(&state); + let user_thing = make_thing_from_enum(ResourceEnum::Users, &claims.user_id); + + // Find the teams that the user is a member of + let teams = match repo.query_teams_by_user(&user_thing).await { + Ok(teams) => teams, + Err(e) => { + error!("Failed to query user teams: {}", e); + return common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to retrieve user teams") + }, + }; + + if teams.is_empty() { + return common_response(StatusCode::BAD_REQUEST, "User is not a member of any team"); + } + + // For now, assume user is in only one team (common case) + // In a future enhancement, we could ask the user to specify which team to leave + let team = &teams[0]; + let team_id = team.id.id.to_raw(); + let team_thing = make_thing_from_enum(ResourceEnum::Teams, &team_id); + + // Check if user is the leader + if team.leader_id.id.to_raw() == claims.user_id { + return common_response(StatusCode::FORBIDDEN, "Team leader cannot leave the team"); + } + + match repo.query_remove_team_member(&team_thing, &user_thing).await { + Ok(msg) => common_response(StatusCode::OK, &format!("Successfully left team: {}", team.name)), + Err(e) => { + error!("Failed to remove team member: {}", e); + return common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to leave team") + }, + } + }) } - }) - } } \ No newline at end of file