Bug: feat: Make invite support email array



Invite api should support invite of multiple users at a time, instead of single email, it should take an array of emails and sent invitation to each.
In case of invitation fails for an email, proper error message should be should be thrown for that email response. Response of multiple invite email will be an array containing email, and the other fields as per success or failure for the response.
diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs
index 8de6a3c0b4f..056d1b593dc 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<Secret<String>>,
 }
 
+#[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<Secret<String>>,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub error: Option<String>,
+}
+
 #[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 f4000755b3e..389cb10d7b5 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<api_models::errors::types::ApiErrorResponse> for UserErrors {
@@ -64,107 +66,118 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
         let sub_code = "UR";
         match self {
             Self::InternalServerError => {
-                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(
-                sub_code,
-                23,
-                "User Role Operation Not Supported",
-                None,
-            )),
-            Self::IpAddressParsingFailed => {
-                AER::InternalServerError(ApiError::new(sub_code, 24, "Something Went Wrong", None))
+            Self::InvalidRoleOperation => {
+                AER::BadRequest(ApiError::new(sub_code, 23, self.get_error_message(), None))
             }
-            Self::InvalidMetadataRequest => AER::BadRequest(ApiError::new(
+            Self::IpAddressParsingFailed => AER::InternalServerError(ApiError::new(
                 sub_code,
-                26,
-                "Invalid Metadata Request",
+                24,
+                self.get_error_message(),
                 None,
             )),
+            Self::InvalidMetadataRequest => {
+                AER::BadRequest(ApiError::new(sub_code, 26, self.get_error_message(), 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,
-                "Old and new password cannot be same",
-                None,
-            )),
-            Self::InvalidDeleteOperation => AER::BadRequest(ApiError::new(
-                sub_code,
-                30,
-                "Delete Operation Not Supported",
-                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))
+            }
+        }
+    }
+}
+
+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 3384e229009..c2ed78f8665 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<user_api::InviteUserRequest>,
+) -> UserResponse<Vec<InviteMultipleUserResponse>> {
+    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<InviteMultipleUserResponse> {
+    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<InviteMultipleUserResponse> {
+    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<InviteMultipleUserResponse> {
+    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 71c79295c73..44822efddc4 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 30348513c2b..6df8c7fb7a7 100644
--- a/crates/router/src/routes/lock_utils.rs
+++ b/crates/router/src/routes/lock_utils.rs
@@ -176,6 +176,7 @@ impl From<Flow> 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 eca32318adf..02704cf701f 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<AppState>,
+    req: HttpRequest,
+    payload: web::Json<Vec<user_api::InviteUserRequest>>,
+) -> 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 84f2e3e1267..998c52f2c13 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

## Type of Change
<!-- Put an `x` in the boxes that apply -->

- [ ] Bugfix
- [x] New feature
- [ ] Enhancement
- [ ] Refactoring
- [ ] Dependency updates
- [ ] Documentation
- [ ] CI/CD

## Description
The endpoint `/invite_multiple`, gives support to invite more than one user at a time.

### Additional Changes

- [x] This PR modifies the API contract
- [ ] This PR modifies the database schema
- [ ] This PR modifies application configuration/environment variables

<!--
Provide links to the files with corresponding changes.

Following are the paths where you can find config files:
1. `config`
2. `crates/router/src/configs`
3. `loadtest/config`
-->


## Motivation and Context
Currently we can only invite one user at a time.


## How did you test it?
Curl for invite_multiple:
```
curl --location 'http://localhost:8080/user/user/invite_multiple' \
--header 'Authorization: Bearer JWT' \
--data-raw '[
    {
    "email": "admin7@juspay.in",
    "name": "25",
    "role_id": "merchant_view_only"
},
{
    "email": "test2@juspay.in",
    "name": "25",  
    "role_id": "merchant_view_only"
},
{
    "email": "12345@juspay.n",
    "name": "25",
    "role_id": "merchant_view_only"
}

]'
```
<img width="1263" alt="Screenshot 2024-01-23 at 4 28 32 AM" src="https://github.com/juspay/hyperswitch/assets/64925866/217cbc2d-a065-4be9-8f04-f3121b017a34">


## Checklist
<!-- Put an `x` in the boxes that apply -->

- [x] I formatted the code `cargo +nightly fmt --all`
- [x] I addressed lints thrown by `cargo clippy`
- [x] I reviewed the submitted code
- [ ] I added unit tests for my changes where possible
- [ ] I added a [CHANGELOG](/CHANGELOG.md) entry if applicable
