This commit is contained in:
MythEclipse
2025-10-27 19:23:29 +07:00
parent cb6eef2054
commit 1caaa8404b
25 changed files with 2936 additions and 11 deletions
+2
View File
@@ -89,5 +89,7 @@ pub use v1::teams::{
PublicTeamsDetailItemDto, TeamsDetailQueryDto, TeamsListQueryDto,
TeamMembersSchema, TeamInvitationsSchema, TeamMembersQueryDto,
TeamInvitationsQueryDto, MemberTeamsDetailItemDto,
AddTeamMemberRequestDto, UpdateMemberRoleRequestDto,
TeamInvitationListDto, MyInvitationDto
};
pub use v1::users::{UsersRepository, UsersSchema, UsersDetailItemDto, UsersCreateRequestDto};
+5 -1
View File
@@ -43,7 +43,11 @@ pub use teams_dto::{
TeamsListQueryDto,
TeamMembersQueryDto,
TeamInvitationsQueryDto,
MemberTeamsDetailItemDto
MemberTeamsDetailItemDto,
AddTeamMemberRequestDto,
UpdateMemberRoleRequestDto,
TeamInvitationListDto,
MyInvitationDto
};
pub use teams_repository::TeamsRepository;
+169 -7
View File
@@ -4,7 +4,9 @@ use crate::{
TeamsCreateRequestDto, TeamsDetailItemDto, TeamsListItemDto, permissions_guard,
TeamsUpdateRequestDto, TeamInviteRequestDto, TeamAcceptInvitationRequestDto,
TeamMemberDto, TeamsSearchQueryDto, PublicTeamsListItemDto, PublicTeamsDetailItemDto,
AdminTeamsListItemDto, AdminTeamsDetailItemDto, PermissionsEnum
AdminTeamsListItemDto, AdminTeamsDetailItemDto, PermissionsEnum,
AddTeamMemberRequestDto, UpdateMemberRoleRequestDto,
TeamInvitationListDto, MyInvitationDto
};
use super::super::teams::{TeamsRepository, TeamMembersSchema};
use axum::response::Response;
@@ -153,12 +155,24 @@ pub async fn put_update_team(
}
}
#[derive(serde::Deserialize)]
pub struct AddTeamMemberRequestDto {
pub user_id: String,
pub role: Option<String>,
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/teams/{id}/members",
params(
("id" = String, Path, description = "Team ID")
),
request_body = AddTeamMemberRequestDto,
responses(
(status = 200, description = "[AUTH] Add member to team successfully", body = ResponseSuccessDto<String>),
(status = 401, description = "[AUTH] Unauthorized"),
(status = 403, description = "[AUTH] Only team leader or members can add"),
(status = 404, description = "[AUTH] Team not found")
),
tag = "Teams"
)]
pub async fn post_add_team_member(
headers: HeaderMap,
Extension(state): Extension<AppState>,
@@ -201,6 +215,24 @@ pub async fn post_add_team_member(
}
}
#[utoipa::path(
delete,
security(
("Bearer" = [])
),
path = "/v1/teams/{id}/members/{user_id}",
params(
("id" = String, Path, description = "Team ID"),
("user_id" = String, Path, description = "User ID to remove")
),
responses(
(status = 200, description = "[AUTH] Member removed successfully", body = ResponseSuccessDto<String>),
(status = 401, description = "[AUTH] Unauthorized"),
(status = 403, description = "[AUTH] Only team leader can remove members"),
(status = 404, description = "[AUTH] Team not found")
),
tag = "Teams"
)]
pub async fn delete_remove_team_member(
headers: HeaderMap,
Extension(state): Extension<AppState>,
@@ -236,6 +268,63 @@ pub async fn delete_remove_team_member(
}
}
#[utoipa::path(
put,
security(
("Bearer" = [])
),
path = "/v1/teams/{id}/members/{user_id}/role",
params(
("id" = String, Path, description = "Team ID"),
("user_id" = String, Path, description = "User ID")
),
request_body = UpdateMemberRoleRequestDto,
responses(
(status = 200, description = "[AUTH] Member role updated successfully", body = ResponseSuccessDto<String>),
(status = 401, description = "[AUTH] Unauthorized"),
(status = 403, description = "[AUTH] Only team leader can update roles"),
(status = 404, description = "[AUTH] Team or member not found")
),
tag = "Teams"
)]
pub async fn put_update_member_role(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path((team_id, user_id)): Path<(String, String)>,
Json(payload): Json<UpdateMemberRoleRequestDto>,
) -> impl IntoResponse {
let state_clone = state.clone();
let is_admin = crate::permissions_guard(headers.clone(), axum::Extension(state_clone.clone()), vec![PermissionsEnum::ManageAllTeams]).await.is_ok();
let auth = permissions_guard(headers, axum::Extension(state.clone()), vec![]).await;
let (claims, state) = match auth {
Ok((c, s)) => (c, s),
Err(response) => return response,
};
let repo = TeamsRepository::new(&state);
let thing_id = imphnen_utils::make_thing_from_enum(imphnen_libs::ResourceEnum::Teams, &team_id);
let team = match repo.query_team_by_id(&thing_id).await {
Ok(t) => t,
Err(_) => return crate::common_response(axum::http::StatusCode::NOT_FOUND, "Team not found"),
};
if !is_admin {
// Only leader can update roles
if team.leader_id.id.to_raw() != claims.user_id {
return crate::common_response(axum::http::StatusCode::FORBIDDEN, "Only team leader can update member roles");
}
}
let user_thing = imphnen_utils::make_thing_from_enum(imphnen_libs::ResourceEnum::Users, &user_id);
match repo.query_update_team_member_role(&thing_id, &user_thing, &payload.role).await {
Ok(_) => crate::success_response(crate::ResponseSuccessDto {
data: format!("Member role updated to: {}", payload.role)
}),
Err(e) => crate::common_response(axum::http::StatusCode::BAD_REQUEST, &e.to_string()),
}
}
#[utoipa::path(
delete,
security(
@@ -410,6 +499,75 @@ pub async fn get_my_team(
authenticated(headers, Extension(state), |claims, state| TeamsService::get_my_team(&state, claims)).await
}
#[utoipa::path(
get,
security(
("Bearer" = [])
),
path = "/v1/teams/{id}/invitations",
params(
("id" = String, Path, description = "Team ID")
),
responses(
(status = 200, description = "[AUTH] Get team invitations", body = ResponseSuccessDto<Vec<TeamInvitationListDto>>),
(status = 401, description = "[AUTH] Unauthorized"),
(status = 403, description = "[AUTH] Only team leader can view invitations"),
(status = 404, description = "[AUTH] Team not found")
),
tag = "Teams"
)]
pub async fn get_team_invitations(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(team_id): Path<String>,
) -> impl IntoResponse {
authenticated(headers, Extension(state), move |claims, state| TeamsService::get_team_invitations(&state, claims, team_id)).await
}
#[utoipa::path(
delete,
security(
("Bearer" = [])
),
path = "/v1/teams/invitations/{token}",
params(
("token" = String, Path, description = "Invitation token")
),
responses(
(status = 200, description = "[AUTH] Invitation cancelled", body = ResponseSuccessDto<String>),
(status = 401, description = "[AUTH] Unauthorized"),
(status = 403, description = "[AUTH] Only team leader can cancel invitations"),
(status = 404, description = "[AUTH] Invitation not found")
),
tag = "Teams"
)]
pub async fn delete_invitation(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(token): Path<String>,
) -> impl IntoResponse {
authenticated(headers, Extension(state), move |claims, state| TeamsService::cancel_invitation(&state, claims, token)).await
}
#[utoipa::path(
get,
security(
("Bearer" = [])
),
path = "/v1/teams/me/invitations",
responses(
(status = 200, description = "[AUTH] Get my pending invitations", body = ResponseSuccessDto<Vec<MyInvitationDto>>),
(status = 401, description = "[AUTH] Unauthorized")
),
tag = "Teams"
)]
pub async fn get_my_invitations(
headers: HeaderMap,
Extension(state): Extension<AppState>,
) -> impl IntoResponse {
authenticated(headers, Extension(state), |claims, state| TeamsService::get_my_invitations(&state, claims)).await
}
#[utoipa::path(
get,
security(
@@ -504,7 +662,11 @@ pub fn teams_router() -> Router {
.route("/{id}/members", axum::routing::get(get_team_members))
.route("/{id}/members", axum::routing::post(post_add_team_member))
.route("/{id}/members/{user_id}", axum::routing::delete(delete_remove_team_member))
.route("/{id}/members/{user_id}/role", axum::routing::put(put_update_member_role))
.route("/{id}/invitations", axum::routing::get(get_team_invitations))
.route("/invitations/{token}", axum::routing::delete(delete_invitation))
.route("/{id}/leave", axum::routing::post(post_leave_team))
.route("/leave-me", axum::routing::post(post_leave_current_team))
.route("/me", axum::routing::get(get_my_team))
.route("/me/invitations", axum::routing::get(get_my_invitations))
}
+47
View File
@@ -479,4 +479,51 @@ impl TeamsDetailQueryDto {
}
}
// Additional DTOs for Team Member Management
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct AddTeamMemberRequestDto {
#[validate(length(min = 1, message = "User ID is required"))]
pub user_id: String,
#[validate(length(max = 50, message = "Role cannot exceed 50 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub role: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct UpdateMemberRoleRequestDto {
#[validate(length(min = 1, max = 50, message = "Role must be between 1 and 50 characters"))]
pub role: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TeamInvitationListDto {
pub id: String,
pub team_id: String,
pub team_name: String,
pub email: String,
pub inviter_id: String,
pub inviter_name: String,
pub status: String,
pub invite_code: String,
pub expires_at: String,
pub invited_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct MyInvitationDto {
pub id: String,
pub team_id: String,
pub team_name: String,
pub team_description: Option<String>,
pub team_avatar: Option<String>,
pub inviter_name: String,
pub invite_code: String,
pub status: String,
pub expires_at: String,
pub invited_at: String,
}
// (previous custom validator removed; using validator::email(each = true) attribute)
+98 -2
View File
@@ -431,6 +431,102 @@ impl<'a> TeamsRepository<'a> {
println!("Query 'query_remove_team_member' took: {elapsed:.2?}");
}
Ok("Success remove team member".into())
Ok("Success remove team member".into())
}
}
pub async fn query_update_team_member_role(&self, team_id: &Thing, user_id: &Thing, role: &str) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let conditions = build_multi_thing_condition(&[("team_id", team_id), ("user_id", user_id)]);
let sql = format!(
"UPDATE {} SET role = '{}' WHERE {} AND is_active = true",
ResourceEnum::TeamMembers,
role,
conditions
);
execute_safe_update_query(db, sql).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_team_member_role' took: {elapsed:.2?}");
}
Ok("Success update team member role".into())
}
pub async fn query_team_invitations(&self, team_id: &Thing) -> Result<Vec<TeamInvitationsQueryDto>> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let team_id_clone = team_id.clone();
let sql = format!(
"SELECT * FROM {} WHERE team_id = $team_id AND status = 'pending' ORDER BY invited_at DESC",
ResourceEnum::TeamInvitations
);
let mut result = db.query(&sql).bind(("team_id", team_id_clone)).await?;
let invitations: Vec<TeamInvitationsQueryDto> = result.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_team_invitations' took: {elapsed:.2?}");
}
Ok(invitations)
}
pub async fn query_user_invitations(&self, email: &str) -> Result<Vec<TeamInvitationsQueryDto>> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let sql = format!(
"SELECT * FROM {} WHERE email = '{}' AND status = 'pending' ORDER BY invited_at DESC",
ResourceEnum::TeamInvitations,
email
);
let mut result = db.query(&sql).await?;
let invitations: Vec<TeamInvitationsQueryDto> = result.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_user_invitations' took: {elapsed:.2?}");
}
Ok(invitations)
}
pub async fn query_delete_invitation(&self, token: &str) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let sql = format!(
"UPDATE {} SET status = 'cancelled' WHERE invite_code = '{}'",
ResourceEnum::TeamInvitations,
token
);
execute_safe_update_query(db, sql).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_invitation' took: {elapsed:.2?}");
}
Ok("Invitation cancelled successfully".into())
}
}
+144 -1
View File
@@ -39,6 +39,9 @@ pub trait TeamsServiceTrait: Send + Sync + 'static {
fn leave_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn leave_current_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn get_my_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn get_team_invitations(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn cancel_invitation(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, token: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn get_my_invitations(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn search_teams(state: &AppState, search_params: TeamsSearchQueryDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn get_admin_team_list(state: &AppState, meta: MetaRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn get_admin_team_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
@@ -1087,4 +1090,144 @@ impl TeamsServiceTrait for TeamsService {
Self::get_public_team_by_id(&state, team_id).await
})
}
}
fn get_team_invitations(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
Box::pin(async move {
let repo = TeamsRepository::new(&state);
let team_thing = make_thing_from_enum(ResourceEnum::Teams, &team_id);
// Check if team exists and user is leader
let team = match repo.query_team_by_id(&team_thing).await {
Ok(t) => t,
Err(_) => return common_response(StatusCode::NOT_FOUND, "Team not found"),
};
if team.leader_id.id.to_raw() != claims.user_id {
return common_response(StatusCode::FORBIDDEN, "Only team leader can view invitations");
}
// Get invitations
match repo.query_team_invitations(&team_thing).await {
Ok(invitations) => {
use crate::{v1::teams::TeamInvitationListDto, UsersRepository};
let users_repo = UsersRepository::new(&state);
let mut invitation_list = Vec::new();
for inv in invitations {
// Get inviter name
let inviter_name = match users_repo.query_user_by_id(&inv.inviter_id).await {
Ok(user) => user.fullname,
Err(_) => "Unknown".to_string(),
};
invitation_list.push(TeamInvitationListDto {
id: inv.id.id.to_raw(),
team_id: inv.team_id.id.to_raw(),
team_name: team.name.clone(),
email: inv.email,
inviter_id: inv.inviter_id.id.to_raw(),
inviter_name,
status: inv.status,
invite_code: inv.invite_code,
expires_at: inv.expires_at,
invited_at: inv.invited_at,
});
}
success_response(ResponseSuccessDto { data: invitation_list })
},
Err(e) => {
error!("Failed to get team invitations: {}", e);
common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to retrieve invitations")
}
}
})
}
fn cancel_invitation(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, token: String) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
Box::pin(async move {
let repo = TeamsRepository::new(&state);
// Get invitation to check ownership
let invitation = match repo.query_invitation_by_token(&token).await {
Ok(inv) => inv,
Err(_) => return common_response(StatusCode::NOT_FOUND, "Invitation not found"),
};
// Check if user is the team leader
let team_thing = invitation.team_id.clone();
let team = match repo.query_team_by_id(&team_thing).await {
Ok(t) => t,
Err(_) => return common_response(StatusCode::NOT_FOUND, "Team not found"),
};
if team.leader_id.id.to_raw() != claims.user_id {
return common_response(StatusCode::FORBIDDEN, "Only team leader can cancel invitations");
}
match repo.query_delete_invitation(&token).await {
Ok(msg) => success_response(ResponseSuccessDto { data: msg }),
Err(e) => {
error!("Failed to cancel invitation: {}", e);
common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to cancel invitation")
}
}
})
}
fn get_my_invitations(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
Box::pin(async move {
use crate::{v1::teams::MyInvitationDto, UsersRepository};
let users_repo = UsersRepository::new(&state);
// Get user email
let user_thing = make_thing_from_enum(ResourceEnum::Users, &claims.user_id);
let user = match users_repo.query_user_by_id(&user_thing).await {
Ok(u) => u,
Err(_) => return common_response(StatusCode::NOT_FOUND, "User not found"),
};
let repo = TeamsRepository::new(&state);
match repo.query_user_invitations(&user.email).await {
Ok(invitations) => {
let mut my_invitations = Vec::new();
for inv in invitations {
// Get team details
let team = match repo.query_team_by_id(&inv.team_id).await {
Ok(t) => t,
Err(_) => continue,
};
// Get inviter name
let inviter_name = match users_repo.query_user_by_id(&inv.inviter_id).await {
Ok(u) => u.fullname,
Err(_) => "Unknown".to_string(),
};
my_invitations.push(MyInvitationDto {
id: inv.id.id.to_raw(),
team_id: inv.team_id.id.to_raw(),
team_name: team.name,
team_description: team.description,
team_avatar: team.avatar,
inviter_name,
invite_code: inv.invite_code,
status: inv.status,
expires_at: inv.expires_at,
invited_at: inv.invited_at,
});
}
success_response(ResponseSuccessDto { data: my_invitations })
},
Err(e) => {
error!("Failed to get user invitations: {}", e);
common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to retrieve invitations")
}
}
})
}
}