Add unit tests for TeamsRepository and TeamsService

- Implement tests for team creation, retrieval, updating, and deletion in TeamsRepository.
- Add tests for team member management including adding and removing members.
- Create tests for team invitations and searching teams.
- Ensure proper cleanup of test data after each test case.
- Validate unauthorized operations for team management.
This commit is contained in:
MythEclipse
2025-09-22 14:59:38 +07:00
parent 6da1b2a7e7
commit 39b75f410f
17 changed files with 2829 additions and 22 deletions
+3
View File
@@ -3,11 +3,13 @@ use axum::Router;
pub mod auth;
pub mod permissions;
pub mod roles;
pub mod teams;
pub mod users;
pub use auth::*;
pub use permissions::*;
pub use roles::*;
pub use teams::*;
pub use users::*;
pub fn iam_public_routes() -> Router {
@@ -19,4 +21,5 @@ pub fn iam_protected_routes() -> Router {
.nest("/users", users_router())
.nest("/roles", roles_router())
.nest("/permissions", permissions_router())
.nest("/teams", teams_router())
}
+30
View File
@@ -0,0 +1,30 @@
pub mod teams_controller;
pub mod teams_dto;
pub mod teams_repository;
pub mod teams_schema;
pub mod teams_service;
pub use teams_controller::*;
pub use teams_dto::*;
pub use teams_repository::*;
pub use teams_schema::*;
pub use teams_service::*;
use axum::{
routing::{delete, get, post, put},
Router,
};
pub fn teams_router() -> Router {
Router::new()
.route("/", get(get_team_list))
.route("/create", post(post_create_team))
.route("/detail/:id", get(get_team_by_id))
.route("/update/:id", put(put_update_team))
.route("/delete/:id", delete(delete_team))
.route("/:id/invite", post(post_invite_team_members))
.route("/accept/:token", post(post_accept_invitation))
.route("/search", get(get_public_team_search))
.route("/:id/members", get(get_team_members))
.route("/:id/leave", post(post_leave_team))
}
@@ -0,0 +1,324 @@
use crate::{AppState, MetaRequestDto};
use crate::{
MessageResponseDto, ResponseListSuccessDto, ResponseSuccessDto,
TeamsCreateRequestDto, TeamsDetailItemDto, TeamsListItemDto, permissions_guard,
TeamsUpdateRequestDto, TeamInviteRequestDto, TeamAcceptInvitationRequestDto,
TeamMemberDto, TeamsSearchQueryDto
};
use axum::extract::Path;
use axum::http::HeaderMap;
use axum::response::IntoResponse;
use axum::{Extension, Json};
use super::teams_service::{TeamsServiceTrait, TeamsService};
#[utoipa::path(
get,
security(
("Bearer" = [])
),
path = "/v1/teams",
params(
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page"),
("search" = Option<String>, Query, description = "Search keyword"),
("sort_by" = Option<String>, Query, description = "Sort by field"),
("order" = Option<String>, Query, description = "Order ASC or DESC"),
("filter" = Option<String>, Query, description = "Filter value"),
("filter_by" = Option<String>, Query, description = "Field to filter by"),
),
responses(
(status = 200, description = "Get team list", body = ResponseListSuccessDto<Vec<TeamsListItemDto>>)
),
tag = "Teams"
)]
pub async fn get_team_list(
headers: HeaderMap,
Extension(state): Extension<AppState>,
axum::extract::Query(meta): axum::extract::Query<MetaRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![],
)
.await
{
Ok((_claims, state)) => TeamsService::get_team_list(&state, meta).await,
Err(response) => response,
}
}
#[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<TeamsDetailItemDto>)
),
tag = "Teams"
)]
pub async fn get_team_by_id(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![],
)
.await
{
Ok((_claims, state)) => TeamsService::get_team_by_id(&state, id).await,
Err(response) => response,
}
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/teams/create",
request_body = TeamsCreateRequestDto,
responses(
(status = 200, description = "Create new team", body = ResponseSuccessDto<serde_json::Value>)
),
tag = "Teams"
)]
pub async fn post_create_team(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Json(payload): Json<TeamsCreateRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![],
)
.await
{
Ok((claims, state)) => TeamsService::create_team(&state, claims, payload).await,
Err(response) => response,
}
}
#[utoipa::path(
put,
security(
("Bearer" = [])
),
path = "/v1/teams/update/{id}",
params(
("id" = String, Path, description = "Team ID")
),
request_body = TeamsUpdateRequestDto,
responses(
(status = 200, description = "Update team", body = MessageResponseDto)
),
tag = "Teams"
)]
pub async fn put_update_team(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
Json(payload): Json<TeamsUpdateRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![],
)
.await
{
Ok((claims, state)) => TeamsService::update_team(&state, claims, id, payload).await,
Err(response) => response,
}
}
#[utoipa::path(
delete,
security(
("Bearer" = [])
),
path = "/v1/teams/delete/{id}",
params(
("id" = String, Path, description = "Team ID")
),
responses(
(status = 200, description = "Delete team", body = MessageResponseDto)
),
tag = "Teams"
)]
pub async fn delete_team(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![],
)
.await
{
Ok((claims, state)) => TeamsService::delete_team(&state, claims, id).await,
Err(response) => response,
}
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/teams/{id}/invite",
params(
("id" = String, Path, description = "Team ID")
),
request_body = TeamInviteRequestDto,
responses(
(status = 200, description = "Invite team members", body = ResponseSuccessDto<serde_json::Value>)
),
tag = "Teams"
)]
pub async fn post_invite_team_members(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(team_id): Path<String>,
Json(payload): Json<TeamInviteRequestDto>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![],
)
.await
{
Ok((claims, state)) => TeamsService::invite_team_members(&state, claims, team_id, payload).await,
Err(response) => response,
}
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/teams/accept/{token}",
params(
("token" = String, Path, description = "Invitation token")
),
responses(
(status = 200, description = "Accept team invitation", body = MessageResponseDto)
),
tag = "Teams"
)]
pub async fn post_accept_invitation(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(token): Path<String>,
) -> impl IntoResponse {
let accept_dto = TeamAcceptInvitationRequestDto { token };
match permissions_guard(
headers,
Extension(state),
vec![],
)
.await
{
Ok((claims, state)) => TeamsService::accept_invitation(&state, claims, accept_dto).await,
Err(response) => response,
}
}
#[utoipa::path(
get,
path = "/v1/teams/search",
params(
("query" = Option<String>, Query, description = "Search query"),
("open" = Option<bool>, Query, description = "Filter by open teams"),
("skills" = Option<Vec<String>>, Query, description = "Filter by required skills"),
("location" = Option<String>, Query, description = "Filter by location"),
("page" = Option<i64>, Query, description = "Page number"),
("per_page" = Option<i64>, Query, description = "Items per page"),
),
responses(
(status = 200, description = "Search teams", body = ResponseListSuccessDto<Vec<TeamsListItemDto>>)
),
tag = "Teams"
)]
pub async fn get_public_team_search(
Extension(state): Extension<AppState>,
axum::extract::Query(search_params): axum::extract::Query<TeamsSearchQueryDto>,
) -> impl IntoResponse {
TeamsService::search_teams(&state, search_params).await
}
#[utoipa::path(
get,
security(
("Bearer" = [])
),
path = "/v1/teams/{id}/members",
params(
("id" = String, Path, description = "Team ID")
),
responses(
(status = 200, description = "Get team members", body = ResponseSuccessDto<Vec<TeamMemberDto>>)
),
tag = "Teams"
)]
pub async fn get_team_members(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![],
)
.await
{
Ok((claims, state)) => TeamsService::get_team_members(&state, claims, id).await,
Err(response) => response,
}
}
#[utoipa::path(
post,
security(
("Bearer" = [])
),
path = "/v1/teams/{id}/leave",
params(
("id" = String, Path, description = "Team ID")
),
responses(
(status = 200, description = "Leave team", body = MessageResponseDto)
),
tag = "Teams"
)]
pub async fn post_leave_team(
headers: HeaderMap,
Extension(state): Extension<AppState>,
Path(id): Path<String>,
) -> impl IntoResponse {
match permissions_guard(
headers,
Extension(state),
vec![],
)
.await
{
Ok((claims, state)) => TeamsService::leave_team(&state, claims, id).await,
Err(response) => response,
}
}
+261
View File
@@ -0,0 +1,261 @@
use serde::{Deserialize, Serialize};
use surrealdb::sql::Thing;
use utoipa::ToSchema;
use validator::Validate;
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct TeamsCreateRequestDto {
#[validate(length(min = 3, max = 100, message = "Team name must be between 3 and 100 characters"))]
pub name: String,
#[validate(length(max = 500, message = "Description cannot exceed 500 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_open: Option<bool>,
#[validate(range(min = 2, max = 50, message = "Max members must be between 2 and 50"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub max_members: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills_required: Option<Vec<String>>,
#[validate(length(max = 100, message = "Location cannot exceed 100 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[validate(url(message = "Invalid avatar URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar: Option<String>,
#[validate(url(message = "Invalid website URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub website_url: Option<String>,
#[validate(url(message = "Invalid GitHub URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
#[validate(length(min = 1, message = "Member emails cannot be empty"))]
pub member_emails: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct TeamsUpdateRequestDto {
#[validate(length(min = 3, max = 100, message = "Team name must be between 3 and 100 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[validate(length(max = 500, message = "Description cannot exceed 500 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub is_open: Option<bool>,
#[validate(range(min = 2, max = 50, message = "Max members must be between 2 and 50"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub max_members: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills_required: Option<Vec<String>>,
#[validate(length(max = 100, message = "Location cannot exceed 100 characters"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[validate(url(message = "Invalid avatar URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar: Option<String>,
#[validate(url(message = "Invalid website URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub website_url: Option<String>,
#[validate(url(message = "Invalid GitHub URL"))]
#[serde(skip_serializing_if = "Option::is_none")]
pub github_url: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema, Validate)]
pub struct TeamInviteRequestDto {
#[validate(length(min = 1, message = "Member emails cannot be empty"))]
pub member_emails: Vec<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TeamAcceptInvitationRequestDto {
pub token: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TeamsDetailItemDto {
pub id: String,
pub name: String,
pub description: Option<String>,
pub leader: TeamMemberDto,
pub is_open: bool,
pub max_members: Option<i32>,
pub current_member_count: i32,
pub skills_required: Option<Vec<String>>,
pub location: Option<String>,
pub avatar: Option<String>,
pub website_url: Option<String>,
pub github_url: Option<String>,
pub members: Option<Vec<TeamMemberDto>>,
pub is_active: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TeamsListItemDto {
pub id: String,
pub name: String,
pub description: Option<String>,
pub leader: TeamMemberDto,
pub is_open: bool,
pub current_member_count: i32,
pub max_members: Option<i32>,
pub skills_required: Option<Vec<String>>,
pub location: Option<String>,
pub avatar: Option<String>,
pub created_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TeamMemberDto {
pub id: String,
pub user_id: String,
pub fullname: String,
pub email: Option<String>,
pub avatar: Option<String>,
pub role: String,
pub skills: Option<Vec<String>>,
pub joined_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TeamInvitationDto {
pub id: String,
pub team_id: String,
pub team_name: String,
pub email: String,
pub inviter_name: String,
pub status: String,
pub expires_at: String,
pub invited_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TeamsDetailQueryDto {
pub id: Thing,
pub name: String,
pub description: Option<String>,
pub leader_id: Thing,
pub is_open: bool,
pub max_members: Option<i32>,
pub skills_required: Option<Vec<String>>,
pub location: Option<String>,
pub avatar: Option<String>,
pub website_url: Option<String>,
pub github_url: Option<String>,
pub is_active: bool,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TeamsListQueryDto {
pub id: Thing,
pub name: String,
pub description: Option<String>,
pub leader_id: Thing,
pub is_open: bool,
pub max_members: Option<i32>,
pub skills_required: Option<Vec<String>>,
pub location: Option<String>,
pub avatar: Option<String>,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TeamMembersQueryDto {
pub id: Thing,
pub team_id: Thing,
pub user_id: Thing,
pub role: String,
pub joined_at: String,
pub is_active: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TeamInvitationsQueryDto {
pub id: Thing,
pub team_id: Thing,
pub email: String,
pub inviter_id: Thing,
pub invite_code: String,
pub expires_at: String,
pub status: String,
pub invited_at: String,
pub accepted_at: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
pub struct TeamsSearchQueryDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub open: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub location: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub per_page: Option<i64>,
}
impl TeamsDetailQueryDto {
pub fn from(self) -> Self {
self
}
}
impl TeamsListQueryDto {
pub fn from(self) -> TeamsListItemDto {
TeamsListItemDto {
id: self.id.id.to_raw(),
name: self.name,
description: self.description,
leader: TeamMemberDto {
id: String::new(),
user_id: self.leader_id.id.to_raw(),
fullname: String::new(),
email: None,
avatar: None,
role: "leader".to_string(),
skills: None,
joined_at: self.created_at.clone(),
},
is_open: self.is_open,
current_member_count: 1,
max_members: self.max_members,
skills_required: self.skills_required,
location: self.location,
avatar: self.avatar,
created_at: self.created_at,
}
}
}
@@ -0,0 +1,447 @@
use super::{
TeamsDetailQueryDto, TeamsListQueryDto, TeamsListItemDto, TeamsSchema,
TeamMembersSchema, TeamInvitationsSchema, TeamMembersQueryDto, TeamInvitationsQueryDto,
TeamsSearchQueryDto
};
use imphnen_libs::{
AppState, MetaRequestDto, ResourceEnum, ResponseListSuccessDto
};
use imphnen_utils::get_id;
use surrealdb::sql::Thing;
use anyhow::{Result, bail};
use imphnen_utils::{DetailQueryBuilder, QueryListBuilder, make_thing_from_enum};
use serde_json;
use std::time::Instant;
pub struct TeamsRepository<'a> {
state: &'a AppState,
}
impl<'a> TeamsRepository<'a> {
pub fn new(state: &'a AppState) -> Self {
Self { state }
}
pub async fn query_team_list(
&self,
meta: MetaRequestDto,
) -> Result<ResponseListSuccessDto<Vec<TeamsListItemDto>>> {
let now = Instant::now();
let result: ResponseListSuccessDto<Vec<TeamsListQueryDto>> =
QueryListBuilder::new(
&self.state.surrealdb_ws,
&ResourceEnum::Teams.to_string(),
&meta,
)
.with_condition("is_deleted = false AND is_active = true")
.search_field("name")
.select_fields(vec!["*"])
.build()
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_team_list' took: {elapsed:.2?}");
}
let data = result
.data
.into_iter()
.map(TeamsListQueryDto::from)
.collect();
Ok(ResponseListSuccessDto {
data,
meta: result.meta,
})
}
pub async fn query_team_by_id(&self, id: &Thing) -> Result<TeamsDetailQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let builder = DetailQueryBuilder::new(ResourceEnum::Teams.to_string())
.with_id(id.id.to_raw())
.with_select_fields(vec!["*"]);
let sql = builder.build();
let result: Option<TeamsDetailQueryDto> =
builder.apply_bindings(db.query(sql)).await?.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_team_by_id' took: {elapsed:.2?}");
}
let Some(team) = result else {
bail!("Team not found");
};
if team.is_deleted {
bail!("Team not found");
}
Ok(TeamsDetailQueryDto::from(team))
}
pub async fn query_create_team(&self, data: TeamsSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let record: Option<TeamsSchema> = db
.create(ResourceEnum::Teams.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_team' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success create team".into()),
None => bail!("Failed to create team"),
}
}
pub async fn query_update_team(&self, data: TeamsSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let record_key = get_id(&data.id)?;
let existing = self.query_team_by_id(&data.id).await?;
if existing.is_deleted {
bail!("Team already deleted");
}
let merged = TeamsSchema {
created_at: existing.created_at,
..data.clone()
};
let record: Option<TeamsSchema> = db.update(record_key).merge(merged).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_team' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update team".into()),
None => bail!("Failed to update team"),
}
}
pub async fn query_delete_team(&self, id: String) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let team = self.query_team_by_id(&make_thing_from_enum(ResourceEnum::Teams, &id)).await?;
if team.is_deleted {
bail!("Team not found");
}
let record_key = get_id(&team.id)?;
let record: Option<TeamsSchema> = db
.update(record_key)
.merge(serde_json::json!({ "is_deleted": true }))
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_delete_team' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success delete team".into()),
None => bail!("Failed to delete team"),
}
}
pub async fn query_add_team_member(&self, data: TeamMembersSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let record: Option<TeamMembersSchema> = db
.create(ResourceEnum::TeamMembers.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_add_team_member' took: {elapsed:.2?}");
}
match record {
Some(saved_member) => {
println!("Member saved with ID: {:?}", saved_member.id);
Ok("Success add team member".into())
},
None => bail!("Failed to add team member"),
}
}
pub async fn query_team_members(&self, team_id: &Thing) -> Result<Vec<TeamMembersQueryDto>> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let sql = format!(
"SELECT * FROM {} WHERE team_id = type::thing('{}', '{}') AND is_active = true",
ResourceEnum::TeamMembers,
team_id.tb,
team_id.id.to_raw()
);
let mut result = db.query(sql).await?;
let members: Vec<TeamMembersQueryDto> = match result.take(0) {
Ok(members) => members,
Err(e) => {
println!("Error getting team members: {:?}", e);
vec![]
}
};
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_team_members' returned {} members", members.len());
println!("Query 'query_team_members' took: {elapsed:.2?}");
}
Ok(members)
}
pub async fn query_user_teams(&self, user_id: &Thing) -> Result<Vec<TeamsDetailQueryDto>> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let sql = format!(
"SELECT team.* FROM {} AS membership
INNER JOIN {} AS team ON membership.team_id = team.id
WHERE membership.user_id = $user_id
AND membership.is_active = true
AND team.is_deleted = false
AND team.is_active = true",
ResourceEnum::TeamMembers,
ResourceEnum::Teams
);
let mut result = db.query(sql).bind(("user_id", user_id.id.to_raw())).await?;
let teams: Vec<TeamsDetailQueryDto> = result.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_user_teams' took: {elapsed:.2?}");
}
Ok(teams)
}
pub async fn query_is_team_member(&self, team_id: &Thing, user_id: &Thing) -> Result<bool> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let sql = format!(
"SELECT COUNT() AS member_count FROM {} WHERE team_id = type::thing('{}', '{}') AND user_id = type::thing('{}', '{}') AND is_active = true",
ResourceEnum::TeamMembers,
team_id.tb,
team_id.id.to_raw(),
user_id.tb,
user_id.id.to_raw()
);
let mut result = db.query(sql).await?;
// Use COUNT query to avoid serialization issues with enum values
let count_result: Vec<serde_json::Value> = match result.take(0) {
Ok(count_result) => count_result,
Err(e) => {
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Error checking team membership: {:?}", e);
}
vec![]
}
};
let member_count = if let Some(first_result) = count_result.first() {
if let Some(count_val) = first_result.get("member_count") {
count_val.as_u64().unwrap_or(0)
} else {
0
}
} else {
0
};
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_is_team_member' found {} matching members", member_count);
println!("Query 'query_is_team_member' took: {elapsed:.2?}");
}
Ok(member_count > 0)
}
pub async fn query_create_invitation(&self, data: TeamInvitationsSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let record: Option<TeamInvitationsSchema> = db
.create(ResourceEnum::TeamInvitations.to_string())
.content(data)
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_create_invitation' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success create invitation".into()),
None => bail!("Failed to create invitation"),
}
}
pub async fn query_invitation_by_token(&self, token: &str) -> Result<TeamInvitationsQueryDto> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let sql = format!(
"SELECT * FROM {} WHERE invite_code = $invite_code AND status = 'pending' LIMIT 1",
ResourceEnum::TeamInvitations
);
let mut result = db.query(sql).bind(("invite_code", token.to_string())).await?;
let invitation: Option<TeamInvitationsQueryDto> = result.take(0)?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_invitation_by_token' took: {elapsed:.2?}");
}
invitation.ok_or_else(|| anyhow::anyhow!("Invitation not found"))
}
pub async fn query_update_invitation(&self, data: TeamInvitationsSchema) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let record_key = get_id(&data.id)?;
let record: Option<TeamInvitationsSchema> = db.update(record_key).merge(data).await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_update_invitation' took: {elapsed:.2?}");
}
match record {
Some(_) => Ok("Success update invitation".into()),
None => bail!("Failed to update invitation"),
}
}
pub async fn query_search_teams(
&self,
search_params: TeamsSearchQueryDto,
) -> Result<ResponseListSuccessDto<Vec<TeamsListItemDto>>> {
let now = Instant::now();
let page = search_params.page.unwrap_or(1);
let per_page = search_params.per_page.unwrap_or(10);
let mut conditions = vec!["is_deleted = false".to_string(), "is_active = true".to_string()];
if let Some(open) = search_params.open {
if open {
conditions.push("is_open = true".to_string());
}
}
if let Some(location) = &search_params.location {
conditions.push(format!("location CONTAINS '{}'", location));
}
let mut query_conditions = conditions.join(" AND ");
if let Some(query) = &search_params.query {
query_conditions = format!("({}) AND (name CONTAINS '{}' OR description CONTAINS '{}')", query_conditions, query, query);
}
if let Some(skills) = &search_params.skills {
for skill in skills.iter() {
query_conditions = format!("{} AND skills_required CONTAINS '{}'", query_conditions, skill);
}
}
let meta = MetaRequestDto {
page: Some(page.try_into().unwrap()),
per_page: Some(per_page.try_into().unwrap()),
search: None, // Don't use built-in search since we're doing custom filtering
sort_by: Some("created_at".to_string()),
order: Some("DESC".to_string()),
filter: None,
filter_by: None,
};
let result: ResponseListSuccessDto<Vec<TeamsListQueryDto>> = QueryListBuilder::new(
&self.state.surrealdb_ws,
&ResourceEnum::Teams.to_string(),
&meta,
)
.with_condition(&query_conditions)
.select_fields(vec!["*"])
.build()
.await?;
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_search_teams' took: {elapsed:.2?}");
}
let data = result
.data
.into_iter()
.map(TeamsListQueryDto::from)
.collect();
Ok(ResponseListSuccessDto {
data,
meta: result.meta,
})
}
pub async fn query_remove_team_member(&self, team_id: &Thing, user_id: &Thing) -> Result<String> {
let now = Instant::now();
let db = &self.state.surrealdb_ws;
let sql = format!(
"UPDATE {} SET is_active = false WHERE team_id = type::thing('{}', '{}') AND user_id = type::thing('{}', '{}')",
ResourceEnum::TeamMembers,
team_id.tb,
team_id.id.to_raw(),
user_id.tb,
user_id.id.to_raw()
);
// Execute the query but don't try to parse the result as it can contain complex enum values
let mut result = db.query(sql).await?;
// Just consume the result without trying to deserialize it to avoid serialization errors
let _: Result<Vec<serde_json::Value>, _> = result.take(0);
let elapsed = now.elapsed();
if std::env::var("RUST_ENV").unwrap_or_else(|_| "development".to_string())
== "development"
{
println!("Query 'query_remove_team_member' took: {elapsed:.2?}");
}
Ok("Success remove team member".into())
}
}
+209
View File
@@ -0,0 +1,209 @@
use super::{TeamsCreateRequestDto, TeamsUpdateRequestDto};
use imphnen_libs::ResourceEnum;
use imphnen_utils::{get_iso_date, make_thing_from_enum};
use serde::{Deserialize, Serialize};
use surrealdb::{Uuid, sql::Thing};
use chrono::{DateTime, Utc, Duration};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TeamsSchema {
pub id: Thing,
pub name: String,
pub description: Option<String>,
pub leader_id: Thing,
pub is_open: bool,
pub max_members: Option<i32>,
pub skills_required: Option<Vec<String>>,
pub location: Option<String>,
pub avatar: Option<String>,
pub website_url: Option<String>,
pub github_url: Option<String>,
pub is_active: bool,
pub is_deleted: bool,
pub created_at: String,
pub updated_at: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TeamMembersSchema {
pub id: Thing,
pub team_id: Thing,
pub user_id: Thing,
pub role: String,
pub joined_at: String,
pub is_active: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TeamInvitationsSchema {
pub id: Thing,
pub team_id: Thing,
pub email: String,
pub inviter_id: Thing,
pub invite_code: String, // Renamed from 'token' to avoid SurrealDB protected field conflict
pub expires_at: DateTime<Utc>,
pub status: String,
pub invited_at: String,
pub accepted_at: Option<String>,
}
impl Default for TeamsSchema {
fn default() -> Self {
Self {
id: make_thing_from_enum(
ResourceEnum::Teams,
&Uuid::new_v4().to_string(),
),
name: String::new(),
description: None,
leader_id: make_thing_from_enum(
ResourceEnum::Users,
&Uuid::new_v4().to_string(),
),
is_open: false,
max_members: None,
skills_required: None,
location: None,
avatar: None,
website_url: None,
github_url: None,
is_active: true,
is_deleted: false,
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
}
impl Default for TeamMembersSchema {
fn default() -> Self {
Self {
id: make_thing_from_enum(
ResourceEnum::TeamMembers,
&Uuid::new_v4().to_string(),
),
team_id: make_thing_from_enum(
ResourceEnum::Teams,
&Uuid::new_v4().to_string(),
),
user_id: make_thing_from_enum(
ResourceEnum::Users,
&Uuid::new_v4().to_string(),
),
role: "member".to_string(),
joined_at: get_iso_date(),
is_active: true,
}
}
}
impl Default for TeamInvitationsSchema {
fn default() -> Self {
Self {
id: make_thing_from_enum(
ResourceEnum::TeamInvitations,
&Uuid::new_v4().to_string(),
),
team_id: make_thing_from_enum(
ResourceEnum::Teams,
&Uuid::new_v4().to_string(),
),
email: String::new(),
inviter_id: make_thing_from_enum(
ResourceEnum::Users,
&Uuid::new_v4().to_string(),
),
invite_code: String::new(), // Renamed from 'token'
expires_at: Utc::now() + Duration::hours(72),
status: "pending".to_string(),
invited_at: get_iso_date(),
accepted_at: None,
}
}
}
impl TeamsSchema {
pub fn create(dto: TeamsCreateRequestDto, leader_id: String) -> Self {
Self {
id: make_thing_from_enum(
ResourceEnum::Teams,
&Uuid::new_v4().to_string(),
),
name: dto.name,
description: dto.description,
leader_id: make_thing_from_enum(ResourceEnum::Users, &leader_id),
is_open: dto.is_open.unwrap_or(false),
max_members: dto.max_members,
skills_required: dto.skills_required,
location: dto.location,
avatar: dto.avatar,
website_url: dto.website_url,
github_url: dto.github_url,
is_active: true,
is_deleted: false,
created_at: get_iso_date(),
updated_at: get_iso_date(),
}
}
pub fn update(self, dto: TeamsUpdateRequestDto) -> Self {
Self {
name: dto.name.unwrap_or(self.name),
description: dto.description.or(self.description),
is_open: dto.is_open.unwrap_or(self.is_open),
max_members: dto.max_members.or(self.max_members),
skills_required: dto.skills_required.or(self.skills_required),
location: dto.location.or(self.location),
avatar: dto.avatar.or(self.avatar),
website_url: dto.website_url.or(self.website_url),
github_url: dto.github_url.or(self.github_url),
updated_at: get_iso_date(),
..self
}
}
}
impl TeamMembersSchema {
pub fn create(team_id: String, user_id: String, role: Option<String>) -> Self {
Self {
id: make_thing_from_enum(
ResourceEnum::TeamMembers,
&Uuid::new_v4().to_string(),
),
team_id: make_thing_from_enum(ResourceEnum::Teams, &team_id),
user_id: make_thing_from_enum(ResourceEnum::Users, &user_id),
role: role.unwrap_or("member".to_string()),
joined_at: get_iso_date(),
is_active: true,
}
}
}
impl TeamInvitationsSchema {
pub fn create(team_id: String, email: String, inviter_id: String, invite_code: String) -> Self {
Self {
id: make_thing_from_enum(
ResourceEnum::TeamInvitations,
&Uuid::new_v4().to_string(),
),
team_id: make_thing_from_enum(ResourceEnum::Teams, &team_id),
email,
inviter_id: make_thing_from_enum(ResourceEnum::Users, &inviter_id),
invite_code, // Renamed from 'token'
expires_at: Utc::now() + Duration::hours(72),
status: "pending".to_string(),
invited_at: get_iso_date(),
accepted_at: None,
}
}
pub fn accept(mut self) -> Self {
self.status = "accepted".to_string();
self.accepted_at = Some(get_iso_date());
self
}
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
}
+603
View File
@@ -0,0 +1,603 @@
use super::{
TeamsCreateRequestDto, TeamsUpdateRequestDto, TeamInviteRequestDto,
TeamAcceptInvitationRequestDto, TeamsDetailItemDto,
TeamMemberDto, TeamsRepository, TeamsSchema, TeamMembersSchema,
TeamInvitationsSchema, TeamsSearchQueryDto
};
use crate::{
AppState, MetaRequestDto, ResponseListSuccessDto, ResponseSuccessDto,
common_response, success_list_response, success_response, validate_request,
UsersRepository
};
use axum::{http::StatusCode, response::Response};
use imphnen_libs::{ResourceEnum, send_email};
use imphnen_utils::{make_thing_from_enum, OtpManager};
use uuid::Uuid;
use std::pin::Pin;
use std::future::Future;
use anyhow::Result;
use tracing::{info, error};
use serde_json::json;
use chrono::Utc;
pub trait TeamsServiceTrait: Send + Sync + 'static {
fn get_team_list(state: &AppState, meta: MetaRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn get_team_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn create_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, new_team: TeamsCreateRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn update_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, id: String, team: TeamsUpdateRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn delete_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn invite_team_members(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String, invite: TeamInviteRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn accept_invitation(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, accept: TeamAcceptInvitationRequestDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn get_team_members(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn leave_team(state: &AppState, claims: imphnen_libs::jsonwebtoken::Claims, team_id: String) -> Pin<Box<dyn Future<Output = Response> + Send>>;
fn search_teams(state: &AppState, search_params: TeamsSearchQueryDto) -> Pin<Box<dyn Future<Output = Response> + Send>>;
}
#[derive(Clone)]
pub struct TeamsService;
impl TeamsService {
async fn send_invitation_email(
team_name: &str,
inviter_name: &str,
email: &str,
token: &str,
is_existing_user: bool,
) -> Result<()> {
let subject = format!("Invitation to join team: {}", team_name);
let (action_text, action_url) = if is_existing_user {
("Login and Accept Invitation", format!("https://app.example.com/login?redirect=/teams/invite/{}", token))
} else {
("Register and Join Team", format!("https://app.example.com/register?team_token={}", token))
};
let body = format!(
"Hello,\n\n\
You have been invited by {} to join the team '{}'.\n\n\
{}\n\
{}\n\n\
This invitation will expire in 72 hours.\n\n\
Best regards,\n\
The Team",
inviter_name, team_name, action_text, action_url
);
send_email(email, &subject, &body)
.map_err(|e| anyhow::anyhow!("Failed to send invitation email: {}", e))?;
info!("Invitation email sent to: {}", email);
Ok(())
}
async fn generate_invitation_token() -> String {
format!("team_{}_{}", Uuid::new_v4(), OtpManager::generate_otp())
}
async fn get_user_info_with_privacy(
user_id: &str,
requester_user_id: &str,
is_team_member: bool,
state: &AppState,
) -> Result<TeamMemberDto> {
let users_repo = UsersRepository::new(state);
let user_thing = make_thing_from_enum(ResourceEnum::Users, user_id);
let user = users_repo.query_user_by_id(&user_thing).await?;
let show_sensitive_data = is_team_member || user_id == requester_user_id;
Ok(TeamMemberDto {
id: String::new(),
user_id: user.id.id.to_raw(),
fullname: user.fullname,
email: if show_sensitive_data { Some(user.email) } else { None },
avatar: user.avatar,
role: "member".to_string(),
skills: user.skills,
joined_at: user.created_at,
})
}
}
impl TeamsServiceTrait for TeamsService {
fn get_team_list(state: &AppState, meta: MetaRequestDto) -> Pin<Box<dyn Future<Output = Response> + 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_team_by_id(state: &AppState, id: String) -> Pin<Box<dyn Future<Output = Response> + 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();
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 create_team(
state: &AppState,
claims: imphnen_libs::jsonwebtoken::Claims,
new_team: TeamsCreateRequestDto,
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
Box::pin(async move {
if let Err((status, message)) = validate_request(&new_team) {
return common_response(status, &message);
}
let repo = TeamsRepository::new(&state);
let users_repo = UsersRepository::new(&state);
let team_schema = TeamsSchema::create(new_team.clone(), claims.user_id.clone());
match repo.query_create_team(team_schema.clone()).await {
Ok(_) => {
let leader_member = TeamMembersSchema::create(
team_schema.id.id.to_raw(),
claims.user_id.clone(),
Some("leader".to_string()),
);
if let Err(e) = repo.query_add_team_member(leader_member).await {
error!("Failed to add team leader as member: {}", e);
}
let mut successful_invites = Vec::new();
let mut failed_invites = Vec::new();
for email in new_team.member_emails {
let existing_user = users_repo.query_user_by_email(email.clone()).await.ok();
let is_existing_user = existing_user.is_some();
let token = Self::generate_invitation_token().await;
let invitation = TeamInvitationsSchema::create(
team_schema.id.id.to_raw(),
email.clone(),
claims.user_id.clone(),
token.clone(),
);
match repo.query_create_invitation(invitation).await {
Ok(_) => {
let inviter_user = match users_repo.query_user_by_id(&make_thing_from_enum(ResourceEnum::Users, &claims.user_id)).await {
Ok(user) => user,
Err(_) => continue,
};
if let Err(e) = Self::send_invitation_email(
&team_schema.name,
&inviter_user.fullname,
&email,
&token,
is_existing_user,
).await {
error!("Failed to send invitation email to {}: {}", email, e);
failed_invites.push(email);
} else {
successful_invites.push(email);
}
}
Err(e) => {
error!("Failed to create invitation for {}: {}", email, e);
failed_invites.push(email);
}
}
}
let response_data = json!({
"team_id": team_schema.id.id.to_raw(),
"message": "Team created successfully",
"invitations_sent": successful_invites.len(),
"invitations_failed": failed_invites.len(),
"failed_emails": failed_invites
});
success_response(ResponseSuccessDto { data: response_data })
}
Err(err) => {
error!("Failed to create team: {}", err);
common_response(StatusCode::INTERNAL_SERVER_ERROR, &err.to_string())
}
}
})
}
fn update_team(
state: &AppState,
claims: imphnen_libs::jsonwebtoken::Claims,
id: String,
team: TeamsUpdateRequestDto,
) -> Pin<Box<dyn Future<Output = Response> + 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");
}
if let Err((status, message)) = validate_request(&team) {
return common_response(status, &message);
}
let repo = TeamsRepository::new(&state);
let thing_id = make_thing_from_enum(ResourceEnum::Teams, &id);
let current_team = match repo.query_team_by_id(&thing_id).await {
Ok(team) => team,
Err(_) => return common_response(StatusCode::NOT_FOUND, "Team not found"),
};
if current_team.leader_id.id.to_raw() != claims.user_id {
return common_response(StatusCode::FORBIDDEN, "Only team leader can update team");
}
let updated_team = TeamsSchema {
id: current_team.id,
leader_id: current_team.leader_id,
is_active: current_team.is_active,
is_deleted: current_team.is_deleted,
created_at: current_team.created_at,
..TeamsSchema::default()
}.update(team);
match repo.query_update_team(updated_team).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
})
}
fn delete_team(
state: &AppState,
claims: imphnen_libs::jsonwebtoken::Claims,
id: String,
) -> Pin<Box<dyn Future<Output = Response> + 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);
let team = match repo.query_team_by_id(&thing_id).await {
Ok(team) => team,
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 delete team");
}
match repo.query_delete_team(id).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
})
}
fn invite_team_members(
state: &AppState,
claims: imphnen_libs::jsonwebtoken::Claims,
team_id: String,
invite: TeamInviteRequestDto,
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
Box::pin(async move {
if Uuid::parse_str(&team_id).is_err() {
return common_response(StatusCode::BAD_REQUEST, "Invalid Team ID format");
}
if let Err((status, message)) = validate_request(&invite) {
return common_response(status, &message);
}
let repo = TeamsRepository::new(&state);
let users_repo = UsersRepository::new(&state);
let thing_id = make_thing_from_enum(ResourceEnum::Teams, &team_id);
let team = match repo.query_team_by_id(&thing_id).await {
Ok(team) => team,
Err(_) => return common_response(StatusCode::NOT_FOUND, "Team not found"),
};
let user_thing = make_thing_from_enum(ResourceEnum::Users, &claims.user_id);
let is_member = repo.query_is_team_member(&thing_id, &user_thing).await.unwrap_or(false);
let is_leader = team.leader_id.id.to_raw() == claims.user_id;
if !is_member && !is_leader {
return common_response(StatusCode::FORBIDDEN, "Only team members can invite others");
}
let mut successful_invites = Vec::new();
let mut failed_invites = Vec::new();
for email in invite.member_emails {
let existing_user = users_repo.query_user_by_email(email.clone()).await.ok();
let is_existing_user = existing_user.is_some();
let token = Self::generate_invitation_token().await;
let invitation = TeamInvitationsSchema::create(
team_id.clone(),
email.clone(),
claims.user_id.clone(),
token.clone(),
);
match repo.query_create_invitation(invitation).await {
Ok(_) => {
let inviter_user = match users_repo.query_user_by_id(&make_thing_from_enum(ResourceEnum::Users, &claims.user_id)).await {
Ok(user) => user,
Err(_) => return common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to get inviter user information"),
};
if let Err(e) = Self::send_invitation_email(
&team.name,
&inviter_user.fullname,
&email,
&token,
is_existing_user,
).await {
error!("Failed to send invitation email to {}: {}", email, e);
failed_invites.push(email);
} else {
successful_invites.push(email);
}
}
Err(e) => {
error!("Failed to create invitation for {}: {}", email, e);
failed_invites.push(email);
}
}
}
let response_data = json!({
"invitations_sent": successful_invites.len(),
"invitations_failed": failed_invites.len(),
"failed_emails": failed_invites
});
success_response(ResponseSuccessDto { data: response_data })
})
}
fn accept_invitation(
state: &AppState,
claims: imphnen_libs::jsonwebtoken::Claims,
accept: TeamAcceptInvitationRequestDto,
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
Box::pin(async move {
let repo = TeamsRepository::new(&state);
let users_repo = UsersRepository::new(&state);
let invitation = match repo.query_invitation_by_token(&accept.token).await {
Ok(inv) => inv,
Err(_) => return common_response(StatusCode::NOT_FOUND, "Invalid or expired invitation"),
};
if invitation.status != "pending" {
return common_response(StatusCode::BAD_REQUEST, "Invitation already processed");
}
if Utc::now().timestamp() > invitation.expires_at.parse::<i64>().unwrap_or(0) {
return common_response(StatusCode::BAD_REQUEST, "Invitation has expired");
}
let user = match users_repo.query_user_by_id(&make_thing_from_enum(ResourceEnum::Users, &claims.user_id)).await {
Ok(user) => user,
Err(_) => return common_response(StatusCode::NOT_FOUND, "User not found"),
};
if user.email != invitation.email {
return common_response(StatusCode::FORBIDDEN, "Invitation email does not match user email");
}
let user_thing = make_thing_from_enum(ResourceEnum::Users, &claims.user_id);
let team_thing = make_thing_from_enum(ResourceEnum::Teams, &invitation.team_id.id.to_raw());
let is_already_member = repo.query_is_team_member(&team_thing, &user_thing).await.unwrap_or(false);
if is_already_member {
return common_response(StatusCode::BAD_REQUEST, "User is already a team member");
}
let member_schema = TeamMembersSchema::create(
invitation.team_id.id.to_raw(),
claims.user_id,
None,
);
match repo.query_add_team_member(member_schema).await {
Ok(_) => {
let updated_invitation = TeamInvitationsSchema {
id: invitation.id,
team_id: invitation.team_id,
email: invitation.email,
inviter_id: invitation.inviter_id,
invite_code: invitation.invite_code,
expires_at: chrono::DateTime::parse_from_rfc3339(&invitation.expires_at)
.unwrap_or_default()
.with_timezone(&Utc),
status: "accepted".to_string(),
invited_at: invitation.invited_at,
accepted_at: Some(chrono::Utc::now().to_rfc3339()),
};
if let Err(e) = repo.query_update_invitation(updated_invitation).await {
error!("Failed to update invitation status: {}", e);
}
common_response(StatusCode::OK, "Successfully joined the team")
}
Err(e) => {
error!("Failed to add team member: {}", e);
common_response(StatusCode::INTERNAL_SERVER_ERROR, "Failed to join team")
}
}
})
}
fn get_team_members(
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 {
if Uuid::parse_str(&team_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, &team_id);
let user_thing = make_thing_from_enum(ResourceEnum::Users, &claims.user_id);
let team = match repo.query_team_by_id(&thing_id).await {
Ok(team) => team,
Err(_) => return common_response(StatusCode::NOT_FOUND, "Team not found"),
};
let is_member = repo.query_is_team_member(&thing_id, &user_thing).await.unwrap_or(false);
let members = match repo.query_team_members(&thing_id).await {
Ok(members) => members,
Err(e) => return common_response(StatusCode::BAD_REQUEST, &e.to_string()),
};
let mut member_dtos = Vec::new();
for member in members {
match Self::get_user_info_with_privacy(
&member.user_id.id.to_raw(),
&claims.user_id,
is_member,
&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,
}
}
match Self::get_user_info_with_privacy(
&team.leader_id.id.to_raw(),
&claims.user_id,
is_member,
&state,
).await {
Ok(mut leader_dto) => {
leader_dto.role = "leader".to_string();
member_dtos.insert(0, leader_dto);
}
Err(_) => {}
}
success_response(ResponseSuccessDto { data: member_dtos })
})
}
fn leave_team(
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 {
if Uuid::parse_str(&team_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, &team_id);
let user_thing = make_thing_from_enum(ResourceEnum::Users, &claims.user_id);
let team = match repo.query_team_by_id(&thing_id).await {
Ok(team) => team,
Err(_) => return common_response(StatusCode::NOT_FOUND, "Team not found"),
};
if team.leader_id.id.to_raw() == claims.user_id {
return common_response(StatusCode::BAD_REQUEST, "Team leader cannot leave the team");
}
let is_member = repo.query_is_team_member(&thing_id, &user_thing).await.unwrap_or(false);
if !is_member {
return common_response(StatusCode::BAD_REQUEST, "User is not a team member");
}
match repo.query_remove_team_member(&thing_id, &user_thing).await {
Ok(msg) => common_response(StatusCode::OK, &msg),
Err(e) => common_response(StatusCode::BAD_REQUEST, &e.to_string()),
}
})
}
fn search_teams(
state: &AppState,
search_params: TeamsSearchQueryDto,
) -> Pin<Box<dyn Future<Output = Response> + Send>> {
let state = state.to_owned();
Box::pin(async move {
let repo = TeamsRepository::new(&state);
match repo.query_search_teams(search_params).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()),
}
})
}
}