Bug: feat: new apis for accept invite and list merchant



- new JWT auth based api for accept invite api
- new SPT auth based api for list merchants
- send accept invite from email instead of reset password for newly invited users
diff --git a/crates/api_models/src/events/user_role.rs b/crates/api_models/src/events/user_role.rs
index 0d42d1de7d6..34375a22848 100644
--- a/crates/api_models/src/events/user_role.rs
+++ b/crates/api_models/src/events/user_role.rs
@@ -7,7 +7,7 @@ use crate::user_role::{
         UpdateRoleRequest,
     },
     AcceptInvitationRequest, AuthorizationInfoResponse, DeleteUserRoleRequest,
-    TransferOrgOwnershipRequest, UpdateUserRoleRequest,
+    MerchantSelectRequest, TransferOrgOwnershipRequest, UpdateUserRoleRequest,
 };
 
 common_utils::impl_misc_api_event_type!(
@@ -15,6 +15,7 @@ common_utils::impl_misc_api_event_type!(
     GetRoleRequest,
     AuthorizationInfoResponse,
     UpdateUserRoleRequest,
+    MerchantSelectRequest,
     AcceptInvitationRequest,
     DeleteUserRoleRequest,
     TransferOrgOwnershipRequest,
diff --git a/crates/api_models/src/user_role.rs b/crates/api_models/src/user_role.rs
index 08d63d9097f..6dde3eb888a 100644
--- a/crates/api_models/src/user_role.rs
+++ b/crates/api_models/src/user_role.rs
@@ -97,11 +97,15 @@ pub enum UserStatus {
 }
 
 #[derive(Debug, serde::Deserialize, serde::Serialize)]
-pub struct AcceptInvitationRequest {
+pub struct MerchantSelectRequest {
     pub merchant_ids: Vec<String>,
     // TODO: Remove this once the token only api is being used
     pub need_dashboard_entry_response: Option<bool>,
 }
+#[derive(Debug, serde::Deserialize, serde::Serialize)]
+pub struct AcceptInvitationRequest {
+    pub merchant_ids: Vec<String>,
+}
 
 #[derive(Debug, serde::Deserialize, serde::Serialize)]
 pub struct DeleteUserRoleRequest {
diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs
index 83cdd1d318b..bb30d30759e 100644
--- a/crates/router/src/core/user.rs
+++ b/crates/router/src/core/user.rs
@@ -7,6 +7,8 @@ use diesel_models::{
     user_role::UserRoleNew,
 };
 use error_stack::{report, ResultExt};
+#[cfg(feature = "email")]
+use external_services::email::EmailData;
 use masking::{ExposeInterface, PeekInterface};
 #[cfg(feature = "email")]
 use router_env::env;
@@ -570,6 +572,7 @@ pub async fn invite_multiple_user(
     user_from_token: auth::UserFromToken,
     requests: Vec<user_api::InviteUserRequest>,
     req_state: ReqState,
+    is_token_only: Option<bool>,
 ) -> UserResponse<Vec<InviteMultipleUserResponse>> {
     if requests.len() > 10 {
         return Err(report!(UserErrors::MaxInvitationsError))
@@ -577,7 +580,8 @@ pub async fn invite_multiple_user(
     }
 
     let responses = futures::future::join_all(requests.iter().map(|request| async {
-        match handle_invitation(&state, &user_from_token, request, &req_state).await {
+        match handle_invitation(&state, &user_from_token, request, &req_state, is_token_only).await
+        {
             Ok(response) => response,
             Err(error) => InviteMultipleUserResponse {
                 email: request.email.clone(),
@@ -597,6 +601,7 @@ async fn handle_invitation(
     user_from_token: &auth::UserFromToken,
     request: &user_api::InviteUserRequest,
     req_state: &ReqState,
+    is_token_only: Option<bool>,
 ) -> UserResult<InviteMultipleUserResponse> {
     let inviter_user = user_from_token.get_user_from_db(state).await?;
 
@@ -635,7 +640,14 @@ async fn handle_invitation(
         .err()
         .unwrap_or(false)
     {
-        handle_new_user_invitation(state, user_from_token, request, req_state.clone()).await
+        handle_new_user_invitation(
+            state,
+            user_from_token,
+            request,
+            req_state.clone(),
+            is_token_only,
+        )
+        .await
     } else {
         Err(UserErrors::InternalServerError.into())
     }
@@ -718,6 +730,7 @@ async fn handle_new_user_invitation(
     user_from_token: &auth::UserFromToken,
     request: &user_api::InviteUserRequest,
     req_state: ReqState,
+    is_token_only: Option<bool>,
 ) -> UserResult<InviteMultipleUserResponse> {
     let new_user = domain::NewUser::try_from((request.clone(), user_from_token.clone()))?;
 
@@ -756,25 +769,36 @@ async fn handle_new_user_invitation(
         })?;
 
     let is_email_sent;
+    // TODO: Adding this to avoid clippy lints, remove this once the token only flow is being used
+    let _ = is_token_only;
+
     #[cfg(feature = "email")]
     {
         // TODO: Adding this to avoid clippy lints
         // Will be adding actual usage for this variable later
         let _ = req_state.clone();
         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!",
-            merchant_id: user_from_token.merchant_id.clone(),
+        let email_contents: Box<dyn EmailData + Send + 'static> = if let Some(true) = is_token_only
+        {
+            Box::new(email_types::InviteRegisteredUser {
+                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!",
+                merchant_id: user_from_token.merchant_id.clone(),
+            })
+        } else {
+            Box::new(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!",
+                merchant_id: user_from_token.merchant_id.clone(),
+            })
         };
         let send_email_result = state
             .email_client
-            .compose_and_send_email(
-                Box::new(email_contents),
-                state.conf.proxy.https_url.as_ref(),
-            )
+            .compose_and_send_email(email_contents, state.conf.proxy.https_url.as_ref())
             .await;
         logger::info!(?send_email_result);
         is_email_sent = send_email_result.is_ok();
@@ -1203,11 +1227,11 @@ pub async fn create_merchant_account(
 
 pub async fn list_merchants_for_user(
     state: AppState,
-    user_from_token: auth::UserFromToken,
+    user_from_token: Box<dyn auth::GetUserIdFromAuth>,
 ) -> UserResponse<Vec<user_api::UserMerchantAccount>> {
     let user_roles = state
         .store
-        .list_user_roles_by_user_id(user_from_token.user_id.as_str())
+        .list_user_roles_by_user_id(user_from_token.get_user_id().as_str())
         .await
         .change_context(UserErrors::InternalServerError)?;
 
diff --git a/crates/router/src/core/user_role.rs b/crates/router/src/core/user_role.rs
index ae20ec51ad4..0d196e1c4af 100644
--- a/crates/router/src/core/user_role.rs
+++ b/crates/router/src/core/user_role.rs
@@ -170,8 +170,38 @@ pub async fn transfer_org_ownership(
 
 pub async fn accept_invitation(
     state: AppState,
-    user_token: auth::UserFromSinglePurposeToken,
+    user_token: auth::UserFromToken,
     req: user_role_api::AcceptInvitationRequest,
+) -> UserResponse<()> {
+    futures::future::join_all(req.merchant_ids.iter().map(|merchant_id| async {
+        state
+            .store
+            .update_user_role_by_user_id_merchant_id(
+                user_token.user_id.as_str(),
+                merchant_id,
+                UserRoleUpdate::UpdateStatus {
+                    status: UserStatus::Active,
+                    modified_by: user_token.user_id.clone(),
+                },
+            )
+            .await
+            .map_err(|e| {
+                logger::error!("Error while accepting invitation {}", e);
+            })
+            .ok()
+    }))
+    .await
+    .into_iter()
+    .reduce(Option::or)
+    .flatten()
+    .ok_or(UserErrors::MerchantIdNotFound.into())
+    .map(|_| ApplicationResponse::StatusOk)
+}
+
+pub async fn merchant_select(
+    state: AppState,
+    user_token: auth::UserFromSinglePurposeToken,
+    req: user_role_api::MerchantSelectRequest,
 ) -> UserResponse<user_api::TokenOrPayloadResponse<user_api::DashboardEntryResponse>> {
     let user_role = futures::future::join_all(req.merchant_ids.iter().map(|merchant_id| async {
         state
@@ -207,7 +237,6 @@ pub async fn accept_invitation(
         utils::user_role::set_role_permissions_in_cache_by_user_role(&state, &user_role).await;
 
         let token = utils::user::generate_jwt_auth_token(&state, &user_from_db, &user_role).await?;
-
         let response = utils::user::get_dashboard_entry_response(
             &state,
             user_from_db,
@@ -223,10 +252,10 @@ pub async fn accept_invitation(
     Ok(ApplicationResponse::StatusOk)
 }
 
-pub async fn accept_invitation_token_only_flow(
+pub async fn merchant_select_token_only_flow(
     state: AppState,
     user_token: auth::UserFromSinglePurposeToken,
-    req: user_role_api::AcceptInvitationRequest,
+    req: user_role_api::MerchantSelectRequest,
 ) -> UserResponse<user_api::TokenOrPayloadResponse<user_api::DashboardEntryResponse>> {
     let user_role = futures::future::join_all(req.merchant_ids.iter().map(|merchant_id| async {
         state
diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs
index 49a8e063183..1c680b8bc8e 100644
--- a/crates/router/src/routes/app.rs
+++ b/crates/router/src/routes/app.rs
@@ -1192,6 +1192,11 @@ impl User {
             // TODO: Remove this endpoint once migration to /merchants/list is done
             .service(web::resource("/switch/list").route(web::get().to(list_merchants_for_user)))
             .service(web::resource("/merchants/list").route(web::get().to(list_merchants_for_user)))
+            // The route is utilized to select an invitation from a list of merchants in an intermediate state
+            .service(
+                web::resource("/merchants_select/list")
+                    .route(web::get().to(list_merchants_for_user_with_spt)),
+            )
             .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(
@@ -1241,7 +1246,11 @@ impl 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("/invite/accept")
+                        .route(web::post().to(merchant_select))
+                        .route(web::put().to(accept_invitation)),
+                )
                 .service(web::resource("/update_role").route(web::post().to(update_user_role)))
                 .service(
                     web::resource("/transfer_ownership")
diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs
index ee42cc50fe3..7b10247dded 100644
--- a/crates/router/src/routes/lock_utils.rs
+++ b/crates/router/src/routes/lock_utils.rs
@@ -220,6 +220,7 @@ impl From<Flow> for ApiIdentifier {
             | Flow::UpdateUserRole
             | Flow::GetAuthorizationInfo
             | Flow::AcceptInvitation
+            | Flow::MerchantSelect
             | Flow::DeleteUserRole
             | Flow::TransferOrgOwnership
             | Flow::CreateRole
diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs
index a901988e51e..6de38bd55a5 100644
--- a/crates/router/src/routes/user.rs
+++ b/crates/router/src/routes/user.rs
@@ -324,6 +324,23 @@ pub async fn list_merchants_for_user(state: web::Data<AppState>, req: HttpReques
     .await
 }
 
+pub async fn list_merchants_for_user_with_spt(
+    state: web::Data<AppState>,
+    req: HttpRequest,
+) -> HttpResponse {
+    let flow = Flow::UserMerchantAccountList;
+    Box::pin(api::server_wrap(
+        flow,
+        state,
+        &req,
+        (),
+        |state, user, _, _| user_core::list_merchants_for_user(state, user),
+        &auth::SinglePurposeJWTAuth(TokenPurpose::AcceptInvite),
+        api_locking::LockAction::NotApplicable,
+    ))
+    .await
+}
+
 pub async fn get_user_role_details(
     state: web::Data<AppState>,
     req: HttpRequest,
@@ -435,14 +452,18 @@ pub async fn invite_multiple_user(
     state: web::Data<AppState>,
     req: HttpRequest,
     payload: web::Json<Vec<user_api::InviteUserRequest>>,
+    query: web::Query<user_api::TokenOnlyQueryParam>,
 ) -> HttpResponse {
     let flow = Flow::InviteMultipleUser;
+    let is_token_only = query.into_inner().token_only;
     Box::pin(api::server_wrap(
         flow,
         state.clone(),
         &req,
         payload.into_inner(),
-        user_core::invite_multiple_user,
+        |state, user, payload, req_state| {
+            user_core::invite_multiple_user(state, user, payload, req_state, is_token_only)
+        },
         &auth::JWTAuth(Permission::UsersWrite),
         api_locking::LockAction::NotApplicable,
     ))
diff --git a/crates/router/src/routes/user_role.rs b/crates/router/src/routes/user_role.rs
index d406783cc71..4239f2d3814 100644
--- a/crates/router/src/routes/user_role.rs
+++ b/crates/router/src/routes/user_role.rs
@@ -209,10 +209,29 @@ pub async fn accept_invitation(
     state: web::Data<AppState>,
     req: HttpRequest,
     json_payload: web::Json<user_role_api::AcceptInvitationRequest>,
-    query: web::Query<user_api::TokenOnlyQueryParam>,
 ) -> HttpResponse {
     let flow = Flow::AcceptInvitation;
     let payload = json_payload.into_inner();
+    Box::pin(api::server_wrap(
+        flow,
+        state.clone(),
+        &req,
+        payload,
+        |state, user, req_body, _| user_role_core::accept_invitation(state, user, req_body),
+        &auth::DashboardNoPermissionAuth,
+        api_locking::LockAction::NotApplicable,
+    ))
+    .await
+}
+
+pub async fn merchant_select(
+    state: web::Data<AppState>,
+    req: HttpRequest,
+    json_payload: web::Json<user_role_api::MerchantSelectRequest>,
+    query: web::Query<user_api::TokenOnlyQueryParam>,
+) -> HttpResponse {
+    let flow = Flow::MerchantSelect;
+    let payload = json_payload.into_inner();
     let is_token_only = query.into_inner().token_only;
     Box::pin(api::server_wrap(
         flow,
@@ -221,9 +240,9 @@ pub async fn accept_invitation(
         payload,
         |state, user, req_body, _| async move {
             if let Some(true) = is_token_only {
-                user_role_core::accept_invitation_token_only_flow(state, user, req_body).await
+                user_role_core::merchant_select_token_only_flow(state, user, req_body).await
             } else {
-                user_role_core::accept_invitation(state, user, req_body).await
+                user_role_core::merchant_select(state, user, req_body).await
             }
         },
         &auth::SinglePurposeJWTAuth(TokenPurpose::AcceptInvite),
diff --git a/crates/router/src/services/authentication.rs b/crates/router/src/services/authentication.rs
index 9aac92acd2a..c7280261c2a 100644
--- a/crates/router/src/services/authentication.rs
+++ b/crates/router/src/services/authentication.rs
@@ -206,6 +206,23 @@ impl AuthInfo for AuthenticationData {
     }
 }
 
+pub trait GetUserIdFromAuth {
+    fn get_user_id(&self) -> String;
+}
+
+impl GetUserIdFromAuth for UserFromToken {
+    fn get_user_id(&self) -> String {
+        self.user_id.clone()
+    }
+}
+
+#[cfg(feature = "olap")]
+impl GetUserIdFromAuth for UserFromSinglePurposeToken {
+    fn get_user_id(&self) -> String {
+        self.user_id.clone()
+    }
+}
+
 #[async_trait]
 pub trait AuthenticateAndFetch<T, A>
 where
@@ -347,6 +364,39 @@ where
     }
 }
 
+#[cfg(feature = "olap")]
+#[async_trait]
+impl<A> AuthenticateAndFetch<Box<dyn GetUserIdFromAuth>, A> for SinglePurposeJWTAuth
+where
+    A: AppStateInfo + Sync,
+{
+    async fn authenticate_and_fetch(
+        &self,
+        request_headers: &HeaderMap,
+        state: &A,
+    ) -> RouterResult<(Box<dyn GetUserIdFromAuth>, AuthenticationType)> {
+        let payload = parse_jwt_payload::<A, SinglePurposeToken>(request_headers, state).await?;
+        if payload.check_in_blacklist(state).await? {
+            return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
+        }
+
+        if self.0 != payload.purpose {
+            return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
+        }
+
+        Ok((
+            Box::new(UserFromSinglePurposeToken {
+                user_id: payload.user_id.clone(),
+                origin: payload.origin.clone(),
+            }),
+            AuthenticationType::SinglePurposeJWT {
+                user_id: payload.user_id,
+                purpose: payload.purpose,
+            },
+        ))
+    }
+}
+
 #[derive(Debug)]
 pub struct AdminApiAuth;
 
@@ -786,6 +836,37 @@ where
     }
 }
 
+#[cfg(feature = "olap")]
+#[async_trait]
+impl<A> AuthenticateAndFetch<Box<dyn GetUserIdFromAuth>, A> for DashboardNoPermissionAuth
+where
+    A: AppStateInfo + Sync,
+{
+    async fn authenticate_and_fetch(
+        &self,
+        request_headers: &HeaderMap,
+        state: &A,
+    ) -> RouterResult<(Box<dyn GetUserIdFromAuth>, AuthenticationType)> {
+        let payload = parse_jwt_payload::<A, AuthToken>(request_headers, state).await?;
+        if payload.check_in_blacklist(state).await? {
+            return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
+        }
+
+        Ok((
+            Box::new(UserFromToken {
+                user_id: payload.user_id.clone(),
+                merchant_id: payload.merchant_id.clone(),
+                org_id: payload.org_id,
+                role_id: payload.role_id,
+            }),
+            AuthenticationType::MerchantJwt {
+                merchant_id: payload.merchant_id,
+                user_id: Some(payload.user_id),
+            },
+        ))
+    }
+}
+
 #[cfg(feature = "olap")]
 #[async_trait]
 impl<A> AuthenticateAndFetch<(), A> for DashboardNoPermissionAuth
diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs
index 9ea86167fcf..a893386ab87 100644
--- a/crates/router_env/src/logger/types.rs
+++ b/crates/router_env/src/logger/types.rs
@@ -386,6 +386,8 @@ pub enum Flow {
     UpdateUserAccountDetails,
     /// Accept user invitation
     AcceptInvitation,
+    /// Select merchant from invitations
+    MerchantSelect,
     /// Initiate external authentication for a payment
     PaymentsExternalAuthentication,
     /// Authorize the payment after external 3ds authentication

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

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

## Description
This PR:
- adds new route to accept invite from dashboard with JWT Auth
- adds new routes to get list of merchants to select for user, SPT Auth
- modifies to send accept invite from email instead of set password for new user in token only flow


### 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
Closes [#4590](https://github.com/juspay/hyperswitch/issues/4590)


## How did you test it?
**To accept invite from inside dashboard** 
```
curl --location --request PUT 'http://localhost:8080/user/user/invite/accept' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer JWT' \
--data '{
    "merchant_ids": [
        "merchant_175173995"
    ]
}'
```
Response will be 200 Ok

**To get merchant select list**
```
curl --location 'http://localhost:8080/user/merchants_select/list' \
--header 'Authorization: Bearer JWT'
```
Respnse will be something like
```
[
    {
        "merchant_id": "merchant_1715075310",
        "merchant_name": "sd",
        "is_active": true,
        "role_id": "merchant_view_only",
        "role_name": "view_only",
        "org_id": "org_BoTucsApoZG6LeGY41hZ"
    },
    {
        "merchant_id": "merchant_1715173995",
        "merchant_name": null,
        "is_active": true,
        "role_id": "merchant_admin",
        "role_name": "admin",
        "org_id": "org_fhL0yj8FWiPhnPnGR7NM"
    }
]
```
For new user invitation we should send accept_invite from email not reset password email
```
curl --location 'http://localhost:8080/user/user/invite_multiple' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer JWT' \
--data-raw '[
    {
        "email": "test+totp@gmail.com",
        "name": "test",
        "role_id": "merchant_admin"
    }
]'
```
Response for email feature flag enable:
```
[
    {
        "email": "apoorvdixit88+ttp@gmail.com",
        "is_email_sent": true
    }
]
```
Also check for what email got sent in inbox.

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