Bug: feat: delete / deactivate user



Add endpoint to support a delete user. 
A user who is part of the organisation can be deleted by users with permission `UsersWrite`, provided user to be deleted should not be the org admin or internal user.
diff --git a/crates/api_models/src/events/user_role.rs b/crates/api_models/src/events/user_role.rs
index c8d8fd96a7a..3ec30d6bd97 100644
--- a/crates/api_models/src/events/user_role.rs
+++ b/crates/api_models/src/events/user_role.rs
@@ -1,8 +1,8 @@
 use common_utils::events::{ApiEventMetric, ApiEventsType};
 
 use crate::user_role::{
-    AcceptInvitationRequest, AuthorizationInfoResponse, GetRoleRequest, ListRolesResponse,
-    RoleInfoResponse, UpdateUserRoleRequest,
+    AcceptInvitationRequest, AuthorizationInfoResponse, DeleteUserRoleRequest, GetRoleRequest,
+    ListRolesResponse, RoleInfoResponse, UpdateUserRoleRequest,
 };
 
 common_utils::impl_misc_api_event_type!(
@@ -11,5 +11,6 @@ common_utils::impl_misc_api_event_type!(
     GetRoleRequest,
     AuthorizationInfoResponse,
     UpdateUserRoleRequest,
-    AcceptInvitationRequest
+    AcceptInvitationRequest,
+    DeleteUserRoleRequest
 );
diff --git a/crates/api_models/src/user_role.rs b/crates/api_models/src/user_role.rs
index d2548935f62..e8c9b777c7f 100644
--- a/crates/api_models/src/user_role.rs
+++ b/crates/api_models/src/user_role.rs
@@ -1,3 +1,5 @@
+use common_utils::pii;
+
 use crate::user::DashboardEntryResponse;
 
 #[derive(Debug, serde::Serialize)]
@@ -101,3 +103,8 @@ pub struct AcceptInvitationRequest {
 }
 
 pub type AcceptInvitationResponse = DashboardEntryResponse;
+
+#[derive(Debug, serde::Deserialize, serde::Serialize)]
+pub struct DeleteUserRoleRequest {
+    pub email: pii::Email,
+}
diff --git a/crates/diesel_models/src/query/dashboard_metadata.rs b/crates/diesel_models/src/query/dashboard_metadata.rs
index 678bcc2fd1f..b1cb034eb1f 100644
--- a/crates/diesel_models/src/query/dashboard_metadata.rs
+++ b/crates/diesel_models/src/query/dashboard_metadata.rs
@@ -104,4 +104,18 @@ impl DashboardMetadata {
         )
         .await
     }
+
+    pub async fn delete_user_scoped_dashboard_metadata_by_merchant_id(
+        conn: &PgPooledConn,
+        user_id: String,
+        merchant_id: String,
+    ) -> StorageResult<bool> {
+        generics::generic_delete::<<Self as HasTable>::Table, _>(
+            conn,
+            dsl::user_id
+                .eq(user_id)
+                .and(dsl::merchant_id.eq(merchant_id)),
+        )
+        .await
+    }
 }
diff --git a/crates/diesel_models/src/query/user_role.rs b/crates/diesel_models/src/query/user_role.rs
index 6b408038ef5..e67eba64c7c 100644
--- a/crates/diesel_models/src/query/user_role.rs
+++ b/crates/diesel_models/src/query/user_role.rs
@@ -54,9 +54,18 @@ impl UserRole {
         .await
     }
 
-    pub async fn delete_by_user_id(conn: &PgPooledConn, user_id: String) -> StorageResult<bool> {
-        generics::generic_delete::<<Self as HasTable>::Table, _>(conn, dsl::user_id.eq(user_id))
-            .await
+    pub async fn delete_by_user_id_merchant_id(
+        conn: &PgPooledConn,
+        user_id: String,
+        merchant_id: String,
+    ) -> StorageResult<bool> {
+        generics::generic_delete::<<Self as HasTable>::Table, _>(
+            conn,
+            dsl::user_id
+                .eq(user_id)
+                .and(dsl::merchant_id.eq(merchant_id)),
+        )
+        .await
     }
 
     pub async fn list_by_user_id(conn: &PgPooledConn, user_id: String) -> StorageResult<Vec<Self>> {
diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs
index 330e02cd547..f4000755b3e 100644
--- a/crates/router/src/core/errors/user.rs
+++ b/crates/router/src/core/errors/user.rs
@@ -54,6 +54,8 @@ pub enum UserErrors {
     MerchantIdParsingError,
     #[error("ChangePasswordError")]
     ChangePasswordError,
+    #[error("InvalidDeleteOperation")]
+    InvalidDeleteOperation,
 }
 
 impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
@@ -157,6 +159,12 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
                 "Old and new password cannot be same",
                 None,
             )),
+            Self::InvalidDeleteOperation => AER::BadRequest(ApiError::new(
+                sub_code,
+                30,
+                "Delete Operation Not Supported",
+                None,
+            )),
         }
     }
 }
diff --git a/crates/router/src/core/user_role.rs b/crates/router/src/core/user_role.rs
index 245f8d246d2..742c281b89a 100644
--- a/crates/router/src/core/user_role.rs
+++ b/crates/router/src/core/user_role.rs
@@ -1,6 +1,7 @@
 use api_models::user_role as user_role_api;
 use diesel_models::{enums::UserStatus, user_role::UserRoleUpdate};
 use error_stack::ResultExt;
+use masking::ExposeInterface;
 use router_env::logger;
 
 use crate::{
@@ -11,6 +12,7 @@ use crate::{
         authorization::{info, predefined_permissions},
         ApplicationResponse,
     },
+    types::domain,
     utils,
 };
 
@@ -161,3 +163,88 @@ pub async fn accept_invitation(
 
     Ok(ApplicationResponse::StatusOk)
 }
+
+pub async fn delete_user_role(
+    state: AppState,
+    user_from_token: auth::UserFromToken,
+    request: user_role_api::DeleteUserRoleRequest,
+) -> UserResponse<()> {
+    let user_from_db: domain::UserFromStorage = state
+        .store
+        .find_user_by_email(
+            domain::UserEmail::from_pii_email(request.email)?
+                .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 records")
+            } else {
+                e.change_context(UserErrors::InternalServerError)
+            }
+        })?
+        .into();
+
+    if user_from_db.get_user_id() == user_from_token.user_id {
+        return Err(UserErrors::InvalidDeleteOperation.into())
+            .attach_printable("User deleting himself");
+    }
+
+    let user_roles = state
+        .store
+        .list_user_roles_by_user_id(user_from_db.get_user_id())
+        .await
+        .change_context(UserErrors::InternalServerError)?;
+
+    match user_roles
+        .iter()
+        .find(|&role| role.merchant_id == user_from_token.merchant_id.as_str())
+    {
+        Some(user_role) => {
+            if !predefined_permissions::is_role_deletable(&user_role.role_id) {
+                return Err(UserErrors::InvalidRoleId.into())
+                    .attach_printable("Deletion not allowed for users with specific role id");
+            }
+        }
+        None => {
+            return Err(UserErrors::InvalidDeleteOperation.into())
+                .attach_printable("User is not associated with the merchant");
+        }
+    };
+
+    if user_roles.len() > 1 {
+        state
+            .store
+            .delete_user_role_by_user_id_merchant_id(
+                user_from_db.get_user_id(),
+                user_from_token.merchant_id.as_str(),
+            )
+            .await
+            .change_context(UserErrors::InternalServerError)
+            .attach_printable("Error while deleting user role")?;
+
+        Ok(ApplicationResponse::StatusOk)
+    } else {
+        state
+            .store
+            .delete_user_by_user_id(user_from_db.get_user_id())
+            .await
+            .change_context(UserErrors::InternalServerError)
+            .attach_printable("Error while deleting user entry")?;
+
+        state
+            .store
+            .delete_user_role_by_user_id_merchant_id(
+                user_from_db.get_user_id(),
+                user_from_token.merchant_id.as_str(),
+            )
+            .await
+            .change_context(UserErrors::InternalServerError)
+            .attach_printable("Error while deleting user role")?;
+
+        Ok(ApplicationResponse::StatusOk)
+    }
+}
diff --git a/crates/router/src/db/dashboard_metadata.rs b/crates/router/src/db/dashboard_metadata.rs
index ec24b4ed07d..8e2ac0b6ad3 100644
--- a/crates/router/src/db/dashboard_metadata.rs
+++ b/crates/router/src/db/dashboard_metadata.rs
@@ -36,6 +36,12 @@ pub trait DashboardMetadataInterface {
         org_id: &str,
         data_keys: Vec<enums::DashboardMetadata>,
     ) -> CustomResult<Vec<storage::DashboardMetadata>, errors::StorageError>;
+
+    async fn delete_user_scoped_dashboard_metadata_by_merchant_id(
+        &self,
+        user_id: &str,
+        merchant_id: &str,
+    ) -> CustomResult<bool, errors::StorageError>;
 }
 
 #[async_trait::async_trait]
@@ -111,6 +117,21 @@ impl DashboardMetadataInterface for Store {
         .map_err(Into::into)
         .into_report()
     }
+    async fn delete_user_scoped_dashboard_metadata_by_merchant_id(
+        &self,
+        user_id: &str,
+        merchant_id: &str,
+    ) -> CustomResult<bool, errors::StorageError> {
+        let conn = connection::pg_connection_write(self).await?;
+        storage::DashboardMetadata::delete_user_scoped_dashboard_metadata_by_merchant_id(
+            &conn,
+            user_id.to_owned(),
+            merchant_id.to_owned(),
+        )
+        .await
+        .map_err(Into::into)
+        .into_report()
+    }
 }
 
 #[async_trait::async_trait]
@@ -246,4 +267,31 @@ impl DashboardMetadataInterface for MockDb {
         }
         Ok(query_result)
     }
+    async fn delete_user_scoped_dashboard_metadata_by_merchant_id(
+        &self,
+        user_id: &str,
+        merchant_id: &str,
+    ) -> CustomResult<bool, errors::StorageError> {
+        let mut dashboard_metadata = self.dashboard_metadata.lock().await;
+
+        let initial_len = dashboard_metadata.len();
+
+        dashboard_metadata.retain(|metadata_inner| {
+            !(metadata_inner
+                .user_id
+                .clone()
+                .map(|user_id_inner| user_id_inner == user_id)
+                .unwrap_or(false)
+                && metadata_inner.merchant_id == merchant_id)
+        });
+
+        if dashboard_metadata.len() == initial_len {
+            return Err(errors::StorageError::ValueNotFound(format!(
+                "No user available for user_id = {user_id} and merchant id = {merchant_id}"
+            ))
+            .into());
+        }
+
+        Ok(true)
+    }
 }
diff --git a/crates/router/src/db/kafka_store.rs b/crates/router/src/db/kafka_store.rs
index 8398c153156..e88d59ea9f3 100644
--- a/crates/router/src/db/kafka_store.rs
+++ b/crates/router/src/db/kafka_store.rs
@@ -1955,9 +1955,14 @@ impl UserRoleInterface for KafkaStore {
             .update_user_role_by_user_id_merchant_id(user_id, merchant_id, update)
             .await
     }
-
-    async fn delete_user_role(&self, user_id: &str) -> CustomResult<bool, errors::StorageError> {
-        self.diesel_store.delete_user_role(user_id).await
+    async fn delete_user_role_by_user_id_merchant_id(
+        &self,
+        user_id: &str,
+        merchant_id: &str,
+    ) -> CustomResult<bool, errors::StorageError> {
+        self.diesel_store
+            .delete_user_role_by_user_id_merchant_id(user_id, merchant_id)
+            .await
     }
 
     async fn list_user_roles_by_user_id(
@@ -2017,6 +2022,16 @@ impl DashboardMetadataInterface for KafkaStore {
             .find_merchant_scoped_dashboard_metadata(merchant_id, org_id, data_keys)
             .await
     }
+
+    async fn delete_user_scoped_dashboard_metadata_by_merchant_id(
+        &self,
+        user_id: &str,
+        merchant_id: &str,
+    ) -> CustomResult<bool, errors::StorageError> {
+        self.diesel_store
+            .delete_user_scoped_dashboard_metadata_by_merchant_id(user_id, merchant_id)
+            .await
+    }
 }
 
 #[async_trait::async_trait]
diff --git a/crates/router/src/db/user_role.rs b/crates/router/src/db/user_role.rs
index d8938f9683d..f02e6d60b3b 100644
--- a/crates/router/src/db/user_role.rs
+++ b/crates/router/src/db/user_role.rs
@@ -32,8 +32,11 @@ pub trait UserRoleInterface {
         merchant_id: &str,
         update: storage::UserRoleUpdate,
     ) -> CustomResult<storage::UserRole, errors::StorageError>;
-
-    async fn delete_user_role(&self, user_id: &str) -> CustomResult<bool, errors::StorageError>;
+    async fn delete_user_role_by_user_id_merchant_id(
+        &self,
+        user_id: &str,
+        merchant_id: &str,
+    ) -> CustomResult<bool, errors::StorageError>;
 
     async fn list_user_roles_by_user_id(
         &self,
@@ -100,12 +103,20 @@ impl UserRoleInterface for Store {
         .into_report()
     }
 
-    async fn delete_user_role(&self, user_id: &str) -> CustomResult<bool, errors::StorageError> {
+    async fn delete_user_role_by_user_id_merchant_id(
+        &self,
+        user_id: &str,
+        merchant_id: &str,
+    ) -> CustomResult<bool, errors::StorageError> {
         let conn = connection::pg_connection_write(self).await?;
-        storage::UserRole::delete_by_user_id(&conn, user_id.to_owned())
-            .await
-            .map_err(Into::into)
-            .into_report()
+        storage::UserRole::delete_by_user_id_merchant_id(
+            &conn,
+            user_id.to_owned(),
+            merchant_id.to_owned(),
+        )
+        .await
+        .map_err(Into::into)
+        .into_report()
     }
 
     async fn list_user_roles_by_user_id(
@@ -230,11 +241,17 @@ impl UserRoleInterface for MockDb {
             )
     }
 
-    async fn delete_user_role(&self, user_id: &str) -> CustomResult<bool, errors::StorageError> {
+    async fn delete_user_role_by_user_id_merchant_id(
+        &self,
+        user_id: &str,
+        merchant_id: &str,
+    ) -> CustomResult<bool, errors::StorageError> {
         let mut user_roles = self.user_roles.lock().await;
         let user_role_index = user_roles
             .iter()
-            .position(|user_role| user_role.user_id == user_id)
+            .position(|user_role| {
+                user_role.user_id == user_id && user_role.merchant_id == merchant_id
+            })
             .ok_or(errors::StorageError::ValueNotFound(format!(
                 "No user available for user_id = {user_id}"
             )))?;
@@ -286,8 +303,14 @@ impl UserRoleInterface for super::KafkaStore {
     ) -> CustomResult<storage::UserRole, errors::StorageError> {
         self.diesel_store.find_user_role_by_user_id(user_id).await
     }
-    async fn delete_user_role(&self, user_id: &str) -> CustomResult<bool, errors::StorageError> {
-        self.diesel_store.delete_user_role(user_id).await
+    async fn delete_user_role_by_user_id_merchant_id(
+        &self,
+        user_id: &str,
+        merchant_id: &str,
+    ) -> CustomResult<bool, errors::StorageError> {
+        self.diesel_store
+            .delete_user_role_by_user_id_merchant_id(user_id, merchant_id)
+            .await
     }
     async fn list_user_roles_by_user_id(
         &self,
diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs
index 4345109a672..5922eeb9fee 100644
--- a/crates/router/src/routes/app.rs
+++ b/crates/router/src/routes/app.rs
@@ -928,7 +928,8 @@ impl User {
                 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)));
 
         #[cfg(feature = "dummy_connector")]
         {
diff --git a/crates/router/src/routes/lock_utils.rs b/crates/router/src/routes/lock_utils.rs
index 1c967222dc7..30348513c2b 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::DeleteUser
             | Flow::UserSignUpWithMerchantId
             | Flow::VerifyEmail
             | Flow::VerifyEmailRequest
diff --git a/crates/router/src/routes/user_role.rs b/crates/router/src/routes/user_role.rs
index 73b1ef1b01d..f83134e5825 100644
--- a/crates/router/src/routes/user_role.rs
+++ b/crates/router/src/routes/user_role.rs
@@ -115,3 +115,21 @@ pub async fn accept_invitation(
     ))
     .await
 }
+
+pub async fn delete_user_role(
+    state: web::Data<AppState>,
+    req: HttpRequest,
+    payload: web::Json<user_role_api::DeleteUserRoleRequest>,
+) -> HttpResponse {
+    let flow = Flow::DeleteUser;
+    Box::pin(api::server_wrap(
+        flow,
+        state.clone(),
+        &req,
+        payload.into_inner(),
+        user_role_core::delete_user_role,
+        &auth::JWTAuth(Permission::UsersWrite),
+        api_locking::LockAction::NotApplicable,
+    ))
+    .await
+}
diff --git a/crates/router/src/services/authorization/predefined_permissions.rs b/crates/router/src/services/authorization/predefined_permissions.rs
index c489f1fc963..6fe0ddcc360 100644
--- a/crates/router/src/services/authorization/predefined_permissions.rs
+++ b/crates/router/src/services/authorization/predefined_permissions.rs
@@ -9,6 +9,7 @@ pub struct RoleInfo {
     permissions: Vec<Permission>,
     name: Option<&'static str>,
     is_invitable: bool,
+    is_deletable: bool,
 }
 
 impl RoleInfo {
@@ -63,6 +64,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy<HashMap<&'static str, RoleInfo>> = Lazy:
             ],
             name: None,
             is_invitable: false,
+            is_deletable: false,
         },
     );
     roles.insert(
@@ -87,6 +89,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy<HashMap<&'static str, RoleInfo>> = Lazy:
             ],
             name: None,
             is_invitable: false,
+            is_deletable: false,
         },
     );
 
@@ -126,6 +129,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy<HashMap<&'static str, RoleInfo>> = Lazy:
             ],
             name: Some("Organization Admin"),
             is_invitable: false,
+            is_deletable: false,
         },
     );
 
@@ -165,6 +169,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy<HashMap<&'static str, RoleInfo>> = Lazy:
             ],
             name: Some("Admin"),
             is_invitable: true,
+            is_deletable: true,
         },
     );
     roles.insert(
@@ -189,6 +194,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy<HashMap<&'static str, RoleInfo>> = Lazy:
             ],
             name: Some("View Only"),
             is_invitable: true,
+            is_deletable: true,
         },
     );
     roles.insert(
@@ -214,6 +220,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy<HashMap<&'static str, RoleInfo>> = Lazy:
             ],
             name: Some("IAM"),
             is_invitable: true,
+            is_deletable: true,
         },
     );
     roles.insert(
@@ -239,6 +246,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy<HashMap<&'static str, RoleInfo>> = Lazy:
             ],
             name: Some("Developer"),
             is_invitable: true,
+            is_deletable: true,
         },
     );
     roles.insert(
@@ -269,6 +277,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy<HashMap<&'static str, RoleInfo>> = Lazy:
             ],
             name: Some("Operator"),
             is_invitable: true,
+            is_deletable: true,
         },
     );
     roles.insert(
@@ -291,6 +300,7 @@ pub static PREDEFINED_PERMISSIONS: Lazy<HashMap<&'static str, RoleInfo>> = Lazy:
             ],
             name: Some("Customer Support"),
             is_invitable: true,
+            is_deletable: true,
         },
     );
     roles
@@ -307,3 +317,9 @@ pub fn is_role_invitable(role_id: &str) -> bool {
         .get(role_id)
         .map_or(false, |role_info| role_info.is_invitable)
 }
+
+pub fn is_role_deletable(role_id: &str) -> bool {
+    PREDEFINED_PERMISSIONS
+        .get(role_id)
+        .map_or(false, |role_info| role_info.is_deletable)
+}
diff --git a/crates/router_env/src/logger/types.rs b/crates/router_env/src/logger/types.rs
index ba323ebc5e3..84f2e3e1267 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,
+    /// Delete user
+    DeleteUser,
     /// Incremental Authorization flow
     PaymentsIncrementalAuthorization,
     /// Get action URL for connector onboarding

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

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

## Description
Add add new endpoint `/user/user/delete` that deletes the user present in the merchant account

Here we have two scenarios
- When user is related to more than one merchant accounts: Delete the user role for that particular merchant account. User will be present in other accounts.
- When user is part of one merchant account: Delete user role and user completely. 

### 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 there is no endpoint to remove the invited users


## How did you test it?
- Singup/Singin
Use the following curl to invite users:
```
curl --location 'http://localhost:8080/user/user/invite' \
--header 'Authorization: Bearer JWT' \
--header 'Content-Type: application/json' \
--data-raw '{
    "email": "user2@juspay.in",
    "name": "user2",
    "role_id": "merchant_admin"
}

```

Use the following curl to delete invited user:
```
curl --location 'http://localhost:8080/user/user/delete' \
--header 'Authorization: Bearer JWT' \
--header 'Content-Type: application/json' \
--data-raw '{
  "email": "invite_from_test21521@juspay.in"
}'
```

Cases to handle:
- only users with permission UsersWrite can delete
- org_admin cannot be deleted
- internal users cannot be deleted
- user cannot delete himself

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