Bug: feat: resend user invite



Add support to resend invites to users. Currently we can invite the user or multiple users, but there is no support to invite the same user again. 

The invitation mail will be send to user and user will be able to accept the invitation.

diff --git a/crates/api_models/src/events/user.rs b/crates/api_models/src/events/user.rs
index 04aabc071ae..d7150af9bc7 100644
--- a/crates/api_models/src/events/user.rs
+++ b/crates/api_models/src/events/user.rs
@@ -12,8 +12,8 @@ use crate::user::{
     },
     AuthorizeResponse, ChangePasswordRequest, ConnectAccountRequest, CreateInternalUserRequest,
     DashboardEntryResponse, ForgotPasswordRequest, GetUsersResponse, InviteUserRequest,
-    InviteUserResponse, ResetPasswordRequest, SendVerifyEmailRequest, SignInResponse,
-    SignUpRequest, SignUpWithMerchantIdRequest, SwitchMerchantIdRequest,
+    InviteUserResponse, ReInviteUserRequest, ResetPasswordRequest, SendVerifyEmailRequest,
+    SignInResponse, SignUpRequest, SignUpWithMerchantIdRequest, SwitchMerchantIdRequest,
     UpdateUserAccountDetailsRequest, UserMerchantCreate, VerifyEmailRequest,
 };
 
@@ -54,6 +54,7 @@ common_utils::impl_misc_api_event_type!(
     ResetPasswordRequest,
     InviteUserRequest,
     InviteUserResponse,
+    ReInviteUserRequest,
     VerifyEmailRequest,
     SendVerifyEmailRequest,
     SignInResponse,
diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs
index 89f42f58c39..b29ce811be3 100644
--- a/crates/api_models/src/user.rs
+++ b/crates/api_models/src/user.rs
@@ -113,6 +113,11 @@ pub struct InviteMultipleUserResponse {
     pub error: Option<String>,
 }
 
+#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
+pub struct ReInviteUserRequest {
+    pub email: pii::Email,
+}
+
 #[derive(Debug, serde::Deserialize, serde::Serialize)]
 pub struct SwitchMerchantIdRequest {
     pub merchant_id: String,
diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs
index 7050c9f0024..41a407bd670 100644
--- a/crates/router/src/core/user.rs
+++ b/crates/router/src/core/user.rs
@@ -706,6 +706,63 @@ async fn handle_new_user_invitation(
     })
 }
 
+#[cfg(feature = "email")]
+pub async fn resend_invite(
+    state: AppState,
+    user_from_token: auth::UserFromToken,
+    request: user_api::ReInviteUserRequest,
+) -> UserResponse<()> {
+    let invitee_email = domain::UserEmail::from_pii_email(request.email)?;
+    let user: domain::UserFromStorage = state
+        .store
+        .find_user_by_email(invitee_email.clone().get_secret().expose().as_str())
+        .await
+        .map_err(|e| {
+            if e.current_context().is_db_not_found() {
+                e.change_context(UserErrors::InvalidRoleOperation)
+                    .attach_printable("User not found in the records")
+            } else {
+                e.change_context(UserErrors::InternalServerError)
+            }
+        })?
+        .into();
+    let user_role = state
+        .store
+        .find_user_role_by_user_id_merchant_id(user.get_user_id(), &user_from_token.merchant_id)
+        .await
+        .map_err(|e| {
+            if e.current_context().is_db_not_found() {
+                e.change_context(UserErrors::InvalidRoleOperation)
+                    .attach_printable("User role with given UserId MerchantId not found")
+            } else {
+                e.change_context(UserErrors::InternalServerError)
+            }
+        })?;
+
+    if !matches!(user_role.status, UserStatus::InvitationSent) {
+        return Err(UserErrors::InvalidRoleOperation.into())
+            .attach_printable("Invalid Status for Reinvitation");
+    }
+
+    let email_contents = email_types::InviteUser {
+        recipient_email: invitee_email,
+        user_name: domain::UserName::new(user.get_name())?,
+        settings: state.conf.clone(),
+        subject: "You have been invited to join Hyperswitch Community!",
+        merchant_id: user_from_token.merchant_id,
+    };
+    state
+        .email_client
+        .compose_and_send_email(
+            Box::new(email_contents),
+            state.conf.proxy.https_url.as_ref(),
+        )
+        .await
+        .change_context(UserErrors::InternalServerError)?;
+
+    Ok(ApplicationResponse::StatusOk)
+}
+
 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 9e8bee73c28..c130da8dbb4 100644
--- a/crates/router/src/routes/app.rs
+++ b/crates/router/src/routes/app.rs
@@ -976,41 +976,12 @@ impl User {
             .service(web::resource("/switch/list").route(web::get().to(list_merchant_ids_for_user)))
             .service(web::resource("/permission_info").route(web::get().to(get_authorization_info)))
             .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))
                     .route(web::post().to(set_dashboard_metadata)),
-            )
-            .service(web::resource("/user/delete").route(web::delete().to(delete_user_role)));
-
-        // User management
-        route = route.service(
-            web::scope("/user")
-                .service(web::resource("/list").route(web::get().to(get_user_details)))
-                .service(web::resource("/invite").route(web::post().to(invite_user)))
-                .service(web::resource("/invite/accept").route(web::post().to(accept_invitation)))
-                .service(web::resource("/update_role").route(web::post().to(update_user_role))),
-        );
-
-        // Role information
-        route = route.service(
-            web::scope("/role")
-                .service(web::resource("").route(web::get().to(get_role_from_token)))
-                .service(web::resource("/list").route(web::get().to(list_all_roles)))
-                .service(web::resource("/{role_id}").route(web::get().to(get_role))),
-        );
+            );
 
-        #[cfg(feature = "dummy_connector")]
-        {
-            route = route.service(
-                web::resource("/sample_data")
-                    .route(web::post().to(generate_sample_data))
-                    .route(web::delete().to(delete_sample_data)),
-            )
-        }
         #[cfg(feature = "email")]
         {
             route = route
@@ -1031,12 +1002,43 @@ impl User {
                 .service(
                     web::resource("/verify_email_request")
                         .route(web::post().to(verify_email_request)),
-                );
+                )
+                .service(web::resource("/user/resend_invite").route(web::post().to(resend_invite)));
         }
         #[cfg(not(feature = "email"))]
         {
             route = route.service(web::resource("/signup").route(web::post().to(user_signup)))
         }
+
+        // User management
+        route = route.service(
+            web::scope("/user")
+                .service(web::resource("/list").route(web::get().to(get_user_details)))
+                .service(web::resource("/invite").route(web::post().to(invite_user)))
+                .service(
+                    web::resource("/invite_multiple").route(web::post().to(invite_multiple_user)),
+                )
+                .service(web::resource("/invite/accept").route(web::post().to(accept_invitation)))
+                .service(web::resource("/update_role").route(web::post().to(update_user_role)))
+                .service(web::resource("/delete").route(web::delete().to(delete_user_role))),
+        );
+
+        // Role information
+        route = route.service(
+            web::scope("/role")
+                .service(web::resource("").route(web::get().to(get_role_from_token)))
+                .service(web::resource("/list").route(web::get().to(list_all_roles)))
+                .service(web::resource("/{role_id}").route(web::get().to(get_role))),
+        );
+
+        #[cfg(feature = "dummy_connector")]
+        {
+            route = route.service(
+                web::resource("/sample_data")
+                    .route(web::post().to(generate_sample_data))
+                    .route(web::delete().to(delete_sample_data)),
+            )
+        }
         route
     }
 }
diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs
index 0dfc3b1b339..042e89fdd52 100644
--- a/crates/router/src/routes/lock_utils.rs
+++ b/crates/router/src/routes/lock_utils.rs
@@ -182,6 +182,7 @@ impl From<Flow> for ApiIdentifier {
             | Flow::ResetPassword
             | Flow::InviteUser
             | Flow::InviteMultipleUser
+            | Flow::ReInviteUser
             | Flow::UserSignUpWithMerchantId
             | Flow::VerifyEmailWithoutInviteChecks
             | Flow::VerifyEmail
diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs
index 0c2694dc70f..a863bc2b662 100644
--- a/crates/router/src/routes/user.rs
+++ b/crates/router/src/routes/user.rs
@@ -401,6 +401,25 @@ pub async fn invite_multiple_user(
     .await
 }
 
+#[cfg(feature = "email")]
+pub async fn resend_invite(
+    state: web::Data<AppState>,
+    req: HttpRequest,
+    payload: web::Json<user_api::ReInviteUserRequest>,
+) -> HttpResponse {
+    let flow = Flow::ReInviteUser;
+    Box::pin(api::server_wrap(
+        flow,
+        state.clone(),
+        &req,
+        payload.into_inner(),
+        user_core::resend_invite,
+        &auth::JWTAuth(Permission::UsersWrite),
+        api_locking::LockAction::NotApplicable,
+    ))
+    .await
+}
+
 #[cfg(feature = "email")]
 pub async fn verify_email_without_invite_checks(
     state: web::Data<AppState>,
diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs
index 55e7bafd8e0..11e6f9c0ed8 100644
--- a/crates/router_env/src/logger/types.rs
+++ b/crates/router_env/src/logger/types.rs
@@ -331,6 +331,8 @@ pub enum Flow {
     InviteUser,
     /// Invite multiple users
     InviteMultipleUser,
+    /// Reinvite user
+    ReInviteUser,
     /// Delete user role
     DeleteUserRole,
     /// 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 new endpoint `/resend_invite`, add supports to send invitation to user again. 

### Additional Changes

- [ ] 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
<!--
Why is this change required? What problem does it solve?
If it fixes an open issue, please link to the issue here.

If you don't have an issue, we'd recommend starting with one first so the PR
can focus on the implementation (unless it is an obvious bug or documentation fix
that will have little conversation).
-->


## How did you test it?
To invite user:
```
curl --location 'http://localhost:8080/user/user/invite' \
--header 'Authorization: Bearer JWT' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "test_2@juspay.in",
    "name": "test2",
    "role_id": "merchant_admin"
}
'
```

For resend invite:
```
curl --location 'http://localhost:8080/user/user/resend_invite' \
--header 'Authorization: Bearer JWT' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "test_2@juspay.in"
}'
```
<img width="1259" alt="Screenshot 2024-02-01 at 5 39 38 PM" src="https://github.com/juspay/hyperswitch/assets/64925866/0fd7df5d-4db6-4e5f-ba6e-0635cbfe9e18">



## 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
