From a59ac7d5b98f27f5fb34206c20ef9c37a07259a3 Mon Sep 17 00:00:00 2001 From: Apoorv Dixit <64925866+apoorvdixit88@users.noreply.github.com> Date: Thu, 25 Jan 2024 18:54:13 +0530 Subject: [PATCH] feat(user): support multiple invites (#3422) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- crates/api_models/src/user.rs | 10 ++ crates/router/src/core/errors/user.rs | 145 ++++++++++--------- crates/router/src/core/user.rs | 187 ++++++++++++++++++++++++- crates/router/src/routes/app.rs | 3 + crates/router/src/routes/lock_utils.rs | 1 + crates/router/src/routes/user.rs | 17 +++ crates/router_env/src/logger/types.rs | 2 + 7 files changed, 296 insertions(+), 69 deletions(-) diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs index 8de6a3c0b4..056d1b593d 100644 --- a/crates/api_models/src/user.rs +++ b/crates/api_models/src/user.rs @@ -89,6 +89,16 @@ pub struct InviteUserResponse { pub password: Option>, } +#[derive(Debug, serde::Serialize)] +pub struct InviteMultipleUserResponse { + pub email: pii::Email, + pub is_email_sent: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + #[derive(Debug, serde::Deserialize, serde::Serialize)] pub struct SwitchMerchantIdRequest { pub merchant_id: String, diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs index f4000755b3..389cb10d7b 100644 --- a/crates/router/src/core/errors/user.rs +++ b/crates/router/src/core/errors/user.rs @@ -56,6 +56,8 @@ pub enum UserErrors { ChangePasswordError, #[error("InvalidDeleteOperation")] InvalidDeleteOperation, + #[error("MaxInvitationsError")] + MaxInvitationsError, } impl common_utils::errors::ErrorSwitch for UserErrors { @@ -64,107 +66,118 @@ impl common_utils::errors::ErrorSwitch { - AER::InternalServerError(ApiError::new("HE", 0, "Something Went Wrong", None)) + AER::InternalServerError(ApiError::new("HE", 0, self.get_error_message(), None)) + } + Self::InvalidCredentials => { + AER::Unauthorized(ApiError::new(sub_code, 1, self.get_error_message(), None)) + } + Self::UserNotFound => { + AER::Unauthorized(ApiError::new(sub_code, 2, self.get_error_message(), None)) + } + Self::UserExists => { + AER::BadRequest(ApiError::new(sub_code, 3, self.get_error_message(), None)) } - Self::InvalidCredentials => AER::Unauthorized(ApiError::new( - sub_code, - 1, - "Incorrect email or password", - None, - )), - Self::UserNotFound => AER::Unauthorized(ApiError::new( - sub_code, - 2, - "Email doesn’t exist. Register", - None, - )), - Self::UserExists => AER::BadRequest(ApiError::new( - sub_code, - 3, - "An account already exists with this email", - None, - )), Self::LinkInvalid => { - AER::Unauthorized(ApiError::new(sub_code, 4, "Invalid or expired link", None)) + AER::Unauthorized(ApiError::new(sub_code, 4, self.get_error_message(), None)) + } + Self::UnverifiedUser => { + AER::Unauthorized(ApiError::new(sub_code, 5, self.get_error_message(), None)) + } + Self::InvalidOldPassword => { + AER::BadRequest(ApiError::new(sub_code, 6, self.get_error_message(), None)) } - Self::UnverifiedUser => AER::Unauthorized(ApiError::new( - sub_code, - 5, - "Kindly verify your account", - None, - )), - Self::InvalidOldPassword => AER::BadRequest(ApiError::new( - sub_code, - 6, - "Old password incorrect. Please enter the correct password", - None, - )), Self::EmailParsingError => { - AER::BadRequest(ApiError::new(sub_code, 7, "Invalid Email", None)) + AER::BadRequest(ApiError::new(sub_code, 7, self.get_error_message(), None)) } Self::NameParsingError => { - AER::BadRequest(ApiError::new(sub_code, 8, "Invalid Name", None)) + AER::BadRequest(ApiError::new(sub_code, 8, self.get_error_message(), None)) } Self::PasswordParsingError => { - AER::BadRequest(ApiError::new(sub_code, 9, "Invalid Password", None)) + AER::BadRequest(ApiError::new(sub_code, 9, self.get_error_message(), None)) } Self::UserAlreadyVerified => { - AER::Unauthorized(ApiError::new(sub_code, 11, "User already verified", None)) + AER::Unauthorized(ApiError::new(sub_code, 11, self.get_error_message(), None)) } Self::CompanyNameParsingError => { - AER::BadRequest(ApiError::new(sub_code, 14, "Invalid Company Name", None)) + AER::BadRequest(ApiError::new(sub_code, 14, self.get_error_message(), None)) } Self::MerchantAccountCreationError(error_message) => { AER::InternalServerError(ApiError::new(sub_code, 15, error_message, None)) } Self::InvalidEmailError => { - AER::BadRequest(ApiError::new(sub_code, 16, "Invalid Email", None)) + AER::BadRequest(ApiError::new(sub_code, 16, self.get_error_message(), None)) } Self::MerchantIdNotFound => { - AER::BadRequest(ApiError::new(sub_code, 18, "Invalid Merchant ID", None)) + AER::BadRequest(ApiError::new(sub_code, 18, self.get_error_message(), None)) } Self::MetadataAlreadySet => { - AER::BadRequest(ApiError::new(sub_code, 19, "Metadata already set", None)) + AER::BadRequest(ApiError::new(sub_code, 19, self.get_error_message(), None)) } Self::DuplicateOrganizationId => AER::InternalServerError(ApiError::new( sub_code, 21, - "An Organization with the id already exists", + self.get_error_message(), None, )), Self::InvalidRoleId => { - AER::BadRequest(ApiError::new(sub_code, 22, "Invalid Role ID", None)) + AER::BadRequest(ApiError::new(sub_code, 22, self.get_error_message(), None)) } - Self::InvalidRoleOperation => AER::BadRequest(ApiError::new( + Self::InvalidRoleOperation => { + AER::BadRequest(ApiError::new(sub_code, 23, self.get_error_message(), None)) + } + Self::IpAddressParsingFailed => AER::InternalServerError(ApiError::new( sub_code, - 23, - "User Role Operation Not Supported", + 24, + self.get_error_message(), None, )), - Self::IpAddressParsingFailed => { - AER::InternalServerError(ApiError::new(sub_code, 24, "Something Went Wrong", None)) + Self::InvalidMetadataRequest => { + AER::BadRequest(ApiError::new(sub_code, 26, self.get_error_message(), None)) } - Self::InvalidMetadataRequest => AER::BadRequest(ApiError::new( - sub_code, - 26, - "Invalid Metadata Request", - None, - )), Self::MerchantIdParsingError => { - AER::BadRequest(ApiError::new(sub_code, 28, "Invalid Merchant Id", None)) + AER::BadRequest(ApiError::new(sub_code, 28, self.get_error_message(), None)) + } + Self::ChangePasswordError => { + AER::BadRequest(ApiError::new(sub_code, 29, self.get_error_message(), None)) + } + Self::InvalidDeleteOperation => { + AER::BadRequest(ApiError::new(sub_code, 30, self.get_error_message(), None)) + } + Self::MaxInvitationsError => { + AER::BadRequest(ApiError::new(sub_code, 31, self.get_error_message(), None)) } - Self::ChangePasswordError => AER::BadRequest(ApiError::new( - sub_code, - 29, - "Old and new password cannot be same", - None, - )), - Self::InvalidDeleteOperation => AER::BadRequest(ApiError::new( - sub_code, - 30, - "Delete Operation Not Supported", - None, - )), + } + } +} + +impl UserErrors { + pub fn get_error_message(&self) -> &str { + match self { + Self::InternalServerError => "Something went wrong", + Self::InvalidCredentials => "Incorrect email or password", + Self::UserNotFound => "Email doesn’t exist. Register", + Self::UserExists => "An account already exists with this email", + Self::LinkInvalid => "Invalid or expired link", + Self::UnverifiedUser => "Kindly verify your account", + Self::InvalidOldPassword => "Old password incorrect. Please enter the correct password", + Self::EmailParsingError => "Invalid Email", + Self::NameParsingError => "Invalid Name", + Self::PasswordParsingError => "Invalid Password", + Self::UserAlreadyVerified => "User already verified", + Self::CompanyNameParsingError => "Invalid Company Name", + Self::MerchantAccountCreationError(error_message) => error_message, + Self::InvalidEmailError => "Invalid Email", + Self::MerchantIdNotFound => "Invalid Merchant ID", + Self::MetadataAlreadySet => "Metadata already set", + Self::DuplicateOrganizationId => "An Organization with the id already exists", + Self::InvalidRoleId => "Invalid Role ID", + Self::InvalidRoleOperation => "User Role Operation Not Supported", + Self::IpAddressParsingFailed => "Something went wrong", + Self::InvalidMetadataRequest => "Invalid Metadata Request", + Self::MerchantIdParsingError => "Invalid Merchant Id", + Self::ChangePasswordError => "Old and new password cannot be the same", + Self::InvalidDeleteOperation => "Delete Operation Not Supported", + Self::MaxInvitationsError => "Maximum invite count per request exceeded", } } } diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs index 3384e22900..c2ed78f866 100644 --- a/crates/router/src/core/user.rs +++ b/crates/router/src/core/user.rs @@ -1,4 +1,4 @@ -use api_models::user as user_api; +use api_models::user::{self as user_api, InviteMultipleUserResponse}; use diesel_models::{enums::UserStatus, user as storage_user, user_role::UserRoleNew}; #[cfg(feature = "email")] use error_stack::IntoReport; @@ -9,7 +9,7 @@ use router_env::env; #[cfg(feature = "email")] use router_env::logger; -use super::errors::{UserErrors, UserResponse}; +use super::errors::{UserErrors, UserResponse, UserResult}; #[cfg(feature = "email")] use crate::services::email::types as email_types; use crate::{ @@ -407,6 +407,12 @@ pub async fn invite_user( .await .change_context(UserErrors::InternalServerError)?; + let invitation_status = if cfg!(feature = "email") { + UserStatus::InvitationSent + } else { + UserStatus::Active + }; + let now = common_utils::date_time::now(); state .store @@ -415,7 +421,7 @@ pub async fn invite_user( merchant_id: user_from_token.merchant_id, role_id: request.role_id, org_id: user_from_token.org_id, - status: UserStatus::InvitationSent, + status: invitation_status, created_by: user_from_token.user_id.clone(), last_modified_by: user_from_token.user_id, created_at: now, @@ -467,6 +473,181 @@ pub async fn invite_user( } } +pub async fn invite_multiple_user( + state: AppState, + user_from_token: auth::UserFromToken, + requests: Vec, +) -> UserResponse> { + if requests.len() > 10 { + return Err(UserErrors::MaxInvitationsError.into()) + .attach_printable("Number of invite requests must not exceed 10"); + } + + let responses = futures::future::join_all(requests.iter().map(|request| async { + match handle_invitation(&state, &user_from_token, request).await { + Ok(response) => response, + Err(error) => InviteMultipleUserResponse { + email: request.email.clone(), + is_email_sent: false, + password: None, + error: Some(error.current_context().get_error_message().to_string()), + }, + } + })) + .await; + + Ok(ApplicationResponse::Json(responses)) +} + +async fn handle_invitation( + state: &AppState, + user_from_token: &auth::UserFromToken, + request: &user_api::InviteUserRequest, +) -> UserResult { + let inviter_user = user_from_token.get_user(state.clone()).await?; + + if inviter_user.email == request.email { + return Err(UserErrors::InvalidRoleOperation.into()) + .attach_printable("User Inviting themself"); + } + + utils::user_role::validate_role_id(request.role_id.as_str())?; + let invitee_email = domain::UserEmail::from_pii_email(request.email.clone())?; + let invitee_user = state + .store + .find_user_by_email(invitee_email.clone().get_secret().expose().as_str()) + .await; + + if let Ok(invitee_user) = invitee_user { + handle_existing_user_invitation(state, user_from_token, request, invitee_user.into()).await + } else if invitee_user + .as_ref() + .map_err(|e| e.current_context().is_db_not_found()) + .err() + .unwrap_or(false) + { + handle_new_user_invitation(state, user_from_token, request).await + } else { + Err(UserErrors::InternalServerError.into()) + } +} + +//TODO: send email +async fn handle_existing_user_invitation( + state: &AppState, + user_from_token: &auth::UserFromToken, + request: &user_api::InviteUserRequest, + invitee_user_from_db: domain::UserFromStorage, +) -> UserResult { + let now = common_utils::date_time::now(); + state + .store + .insert_user_role(UserRoleNew { + user_id: invitee_user_from_db.get_user_id().to_owned(), + merchant_id: user_from_token.merchant_id.clone(), + role_id: request.role_id.clone(), + org_id: user_from_token.org_id.clone(), + status: UserStatus::Active, + created_by: user_from_token.user_id.clone(), + last_modified_by: user_from_token.user_id.clone(), + created_at: now, + last_modified: now, + }) + .await + .map_err(|e| { + if e.current_context().is_db_unique_violation() { + e.change_context(UserErrors::UserExists) + } else { + e.change_context(UserErrors::InternalServerError) + } + })?; + + Ok(InviteMultipleUserResponse { + email: request.email.clone(), + is_email_sent: false, + password: None, + error: None, + }) +} + +async fn handle_new_user_invitation( + state: &AppState, + user_from_token: &auth::UserFromToken, + request: &user_api::InviteUserRequest, +) -> UserResult { + let new_user = domain::NewUser::try_from((request.clone(), user_from_token.clone()))?; + + new_user + .insert_user_in_db(state.store.as_ref()) + .await + .change_context(UserErrors::InternalServerError)?; + + let invitation_status = if cfg!(feature = "email") { + UserStatus::InvitationSent + } else { + UserStatus::Active + }; + + let now = common_utils::date_time::now(); + state + .store + .insert_user_role(UserRoleNew { + user_id: new_user.get_user_id().to_owned(), + merchant_id: user_from_token.merchant_id.clone(), + role_id: request.role_id.clone(), + org_id: user_from_token.org_id.clone(), + status: invitation_status, + created_by: user_from_token.user_id.clone(), + last_modified_by: user_from_token.user_id.clone(), + created_at: now, + last_modified: now, + }) + .await + .map_err(|e| { + if e.current_context().is_db_unique_violation() { + e.change_context(UserErrors::UserExists) + } else { + e.change_context(UserErrors::InternalServerError) + } + })?; + + let is_email_sent; + #[cfg(feature = "email")] + { + let invitee_email = domain::UserEmail::from_pii_email(request.email.clone())?; + let email_contents = email_types::InviteUser { + recipient_email: invitee_email, + user_name: domain::UserName::new(new_user.get_name())?, + settings: state.conf.clone(), + subject: "You have been invited to join Hyperswitch Community!", + }; + let send_email_result = state + .email_client + .compose_and_send_email( + Box::new(email_contents), + state.conf.proxy.https_url.as_ref(), + ) + .await; + logger::info!(?send_email_result); + is_email_sent = send_email_result.is_ok(); + } + #[cfg(not(feature = "email"))] + { + is_email_sent = false; + } + + Ok(InviteMultipleUserResponse { + is_email_sent, + password: if cfg!(not(feature = "email")) { + Some(new_user.get_password().get_secret()) + } else { + None + }, + email: request.email.clone(), + error: None, + }) +} + pub async fn create_internal_user( state: AppState, request: user_api::CreateInternalUserRequest, diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index 71c79295c7..44822efddc 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -970,6 +970,9 @@ impl User { .service(web::resource("/user/invite").route(web::post().to(invite_user))) .service(web::resource("/user/invite/accept").route(web::post().to(accept_invitation))) .service(web::resource("/update").route(web::post().to(update_user_account_details))) + .service( + web::resource("/user/invite_multiple").route(web::post().to(invite_multiple_user)), + ) .service( web::resource("/data") .route(web::get().to(get_multiple_dashboard_metadata)) diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs index 30348513c2..6df8c7fb7a 100644 --- a/crates/router/src/routes/lock_utils.rs +++ b/crates/router/src/routes/lock_utils.rs @@ -176,6 +176,7 @@ impl From for ApiIdentifier { | Flow::ForgotPassword | Flow::ResetPassword | Flow::InviteUser + | Flow::InviteMultipleUser | Flow::DeleteUser | Flow::UserSignUpWithMerchantId | Flow::VerifyEmail diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs index eca32318ad..02704cf701 100644 --- a/crates/router/src/routes/user.rs +++ b/crates/router/src/routes/user.rs @@ -350,6 +350,23 @@ pub async fn invite_user( )) .await } +pub async fn invite_multiple_user( + state: web::Data, + req: HttpRequest, + payload: web::Json>, +) -> HttpResponse { + let flow = Flow::InviteMultipleUser; + Box::pin(api::server_wrap( + flow, + state.clone(), + &req, + payload.into_inner(), + user_core::invite_multiple_user, + &auth::JWTAuth(Permission::UsersWrite), + api_locking::LockAction::NotApplicable, + )) + .await +} #[cfg(feature = "email")] pub async fn verify_email( diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs index 84f2e3e126..998c52f2c1 100644 --- a/crates/router_env/src/logger/types.rs +++ b/crates/router_env/src/logger/types.rs @@ -321,6 +321,8 @@ pub enum Flow { ResetPassword, /// Invite users InviteUser, + /// Invite multiple users + InviteMultipleUser, /// Delete user DeleteUser, /// Incremental Authorization flow