Bug: feat: support profile level delete in V1 - V2



Add support to delete user role 
- Support profile level delete along with merchant level delete.
- In v1v2 version, delete from both V1 and V2

diff --git a/crates/common_enums/src/enums.rs b/crates/common_enums/src/enums.rs
index 015bf2d6c68..b81fb6e04e8 100644
--- a/crates/common_enums/src/enums.rs
+++ b/crates/common_enums/src/enums.rs
@@ -3085,6 +3085,8 @@ pub enum ApiVersion {
     Debug,
     Eq,
     PartialEq,
+    Ord,
+    PartialOrd,
     serde::Deserialize,
     serde::Serialize,
     strum::Display,
@@ -3095,10 +3097,10 @@ pub enum ApiVersion {
 #[strum(serialize_all = "snake_case")]
 #[serde(rename_all = "snake_case")]
 pub enum EntityType {
-    Internal,
-    Organization,
-    Merchant,
-    Profile,
+    Internal = 3,
+    Organization = 2,
+    Merchant = 1,
+    Profile = 0,
 }
 
 #[derive(Clone, Debug, serde::Serialize)]
diff --git a/crates/diesel_models/src/query/user_role.rs b/crates/diesel_models/src/query/user_role.rs
index 2f2923db000..b84ea63de1d 100644
--- a/crates/diesel_models/src/query/user_role.rs
+++ b/crates/diesel_models/src/query/user_role.rs
@@ -82,22 +82,6 @@ impl UserRole {
         .await
     }
 
-    pub async fn delete_by_user_id_merchant_id(
-        conn: &PgPooledConn,
-        user_id: String,
-        merchant_id: id_type::MerchantId,
-        version: UserRoleVersion,
-    ) -> StorageResult<Self> {
-        generics::generic_delete_one_with_result::<<Self as HasTable>::Table, _, _>(
-            conn,
-            dsl::user_id
-                .eq(user_id)
-                .and(dsl::merchant_id.eq(merchant_id))
-                .and(dsl::version.eq(version)),
-        )
-        .await
-    }
-
     pub async fn list_by_user_id(
         conn: &PgPooledConn,
         user_id: String,
@@ -129,4 +113,71 @@ impl UserRole {
         )
         .await
     }
+
+    pub async fn find_by_user_id_org_id_merchant_id_profile_id(
+        conn: &PgPooledConn,
+        user_id: String,
+        org_id: id_type::OrganizationId,
+        merchant_id: id_type::MerchantId,
+        profile_id: Option<String>,
+        version: UserRoleVersion,
+    ) -> StorageResult<Self> {
+        // Checking in user roles, for a user in token hierarchy, only one of the relation will be true, either org level, merchant level or profile level
+        // (org_id = ? && merchant_id = null && profile_id = null)  || (org_id = ? && merchant_id = ? && profile_id = null) || (org_id = ? && merchant_id = ? && profile_id = ?)
+        let check_lineage = dsl::org_id
+            .eq(org_id.clone())
+            .and(dsl::merchant_id.is_null().and(dsl::profile_id.is_null()))
+            .or(dsl::org_id.eq(org_id.clone()).and(
+                dsl::merchant_id
+                    .eq(merchant_id.clone())
+                    .and(dsl::profile_id.is_null()),
+            ))
+            .or(dsl::org_id.eq(org_id).and(
+                dsl::merchant_id
+                    .eq(merchant_id)
+                    //TODO: In case of None, profile_id = NULL its unexpected behaviour, after V1 profile id will not be option
+                    .and(dsl::profile_id.eq(profile_id)),
+            ));
+
+        let predicate = dsl::user_id
+            .eq(user_id)
+            .and(check_lineage)
+            .and(dsl::version.eq(version));
+
+        generics::generic_find_one::<<Self as HasTable>::Table, _, _>(conn, predicate).await
+    }
+
+    pub async fn delete_by_user_id_org_id_merchant_id_profile_id(
+        conn: &PgPooledConn,
+        user_id: String,
+        org_id: id_type::OrganizationId,
+        merchant_id: id_type::MerchantId,
+        profile_id: Option<String>,
+        version: UserRoleVersion,
+    ) -> StorageResult<Self> {
+        // Checking in user roles, for a user in token hierarchy, only one of the relation will be true, either org level, merchant level or profile level
+        // (org_id = ? && merchant_id = null && profile_id = null)  || (org_id = ? && merchant_id = ? && profile_id = null) || (org_id = ? && merchant_id = ? && profile_id = ?)
+        let check_lineage = dsl::org_id
+            .eq(org_id.clone())
+            .and(dsl::merchant_id.is_null().and(dsl::profile_id.is_null()))
+            .or(dsl::org_id.eq(org_id.clone()).and(
+                dsl::merchant_id
+                    .eq(merchant_id.clone())
+                    .and(dsl::profile_id.is_null()),
+            ))
+            .or(dsl::org_id.eq(org_id).and(
+                dsl::merchant_id
+                    .eq(merchant_id)
+                    //TODO: In case of None, profile_id = NULL its unexpected behaviour, after V1 profile id will not be option
+                    .and(dsl::profile_id.eq(profile_id)),
+            ));
+
+        let predicate = dsl::user_id
+            .eq(user_id)
+            .and(check_lineage)
+            .and(dsl::version.eq(version));
+
+        generics::generic_delete_one_with_result::<<Self as HasTable>::Table, _, _>(conn, predicate)
+            .await
+    }
 }
diff --git a/crates/router/src/core/user_role.rs b/crates/router/src/core/user_role.rs
index b5c7f6db016..f2fddd61bb6 100644
--- a/crates/router/src/core/user_role.rs
+++ b/crates/router/src/core/user_role.rs
@@ -342,68 +342,169 @@ pub async fn delete_user_role(
             .attach_printable("User deleting himself");
     }
 
-    let user_roles = state
+    let deletion_requestor_role_info = roles::RoleInfo::from_role_id(
+        &state,
+        &user_from_token.role_id,
+        &user_from_token.merchant_id,
+        &user_from_token.org_id,
+    )
+    .await
+    .change_context(UserErrors::InternalServerError)?;
+
+    let mut user_role_deleted_flag = false;
+
+    // Find in V2
+    let user_role_v2 = match state
         .store
-        .list_user_roles_by_user_id(user_from_db.get_user_id(), UserRoleVersion::V1)
+        .find_user_role_by_user_id_and_lineage(
+            user_from_db.get_user_id(),
+            &user_from_token.org_id,
+            &user_from_token.merchant_id,
+            user_from_token.profile_id.as_ref(),
+            UserRoleVersion::V2,
+        )
+        .await
+    {
+        Ok(user_role) => Some(user_role),
+        Err(e) => {
+            if e.current_context().is_db_not_found() {
+                None
+            } else {
+                return Err(UserErrors::InternalServerError.into());
+            }
+        }
+    };
+
+    if let Some(role_to_be_deleted) = user_role_v2 {
+        let target_role_info = roles::RoleInfo::from_role_id(
+            &state,
+            &role_to_be_deleted.role_id,
+            &user_from_token.merchant_id,
+            &user_from_token.org_id,
+        )
         .await
         .change_context(UserErrors::InternalServerError)?;
 
-    for user_role in user_roles.iter() {
-        let Some(merchant_id) = user_role.merchant_id.as_ref() else {
-            return Err(report!(UserErrors::InternalServerError))
-                .attach_printable("merchant_id not found for user_role");
-        };
+        if !target_role_info.is_deletable() {
+            return Err(report!(UserErrors::InvalidDeleteOperation)).attach_printable(format!(
+                "Invalid operation, role_id = {} is not deletable",
+                role_to_be_deleted.role_id
+            ));
+        }
 
-        if merchant_id == &user_from_token.merchant_id {
-            let role_info = roles::RoleInfo::from_role_id(
-                &state,
-                &user_role.role_id,
-                &user_from_token.merchant_id,
+        if deletion_requestor_role_info.get_entity_type() < target_role_info.get_entity_type() {
+            return Err(report!(UserErrors::InvalidDeleteOperation)).attach_printable(format!(
+                "Invalid operation, deletion requestor = {} cannot delete target = {}",
+                deletion_requestor_role_info.get_entity_type(),
+                target_role_info.get_entity_type()
+            ));
+        }
+
+        user_role_deleted_flag = true;
+        state
+            .store
+            .delete_user_role_by_user_id_and_lineage(
+                user_from_db.get_user_id(),
                 &user_from_token.org_id,
+                &user_from_token.merchant_id,
+                user_from_token.profile_id.as_ref(),
+                UserRoleVersion::V2,
             )
             .await
-            .change_context(UserErrors::InternalServerError)?;
-            if !role_info.is_deletable() {
-                return Err(report!(UserErrors::InvalidDeleteOperation))
-                    .attach_printable(format!("role_id = {} is not deletable", user_role.role_id));
+            .change_context(UserErrors::InternalServerError)
+            .attach_printable("Error while deleting user role")?;
+    }
+
+    // Find in V1
+    let user_role_v1 = match state
+        .store
+        .find_user_role_by_user_id_and_lineage(
+            user_from_db.get_user_id(),
+            &user_from_token.org_id,
+            &user_from_token.merchant_id,
+            user_from_token.profile_id.as_ref(),
+            UserRoleVersion::V1,
+        )
+        .await
+    {
+        Ok(user_role) => Some(user_role),
+        Err(e) => {
+            if e.current_context().is_db_not_found() {
+                None
+            } else {
+                return Err(UserErrors::InternalServerError.into());
             }
-        } else {
-            return Err(report!(UserErrors::InvalidDeleteOperation))
-                .attach_printable("User is not associated with the merchant");
         }
-    }
+    };
+
+    if let Some(role_to_be_deleted) = user_role_v1 {
+        let target_role_info = roles::RoleInfo::from_role_id(
+            &state,
+            &role_to_be_deleted.role_id,
+            &user_from_token.merchant_id,
+            &user_from_token.org_id,
+        )
+        .await
+        .change_context(UserErrors::InternalServerError)?;
+
+        if !target_role_info.is_deletable() {
+            return Err(report!(UserErrors::InvalidDeleteOperation)).attach_printable(format!(
+                "Invalid operation, role_id = {} is not deletable",
+                role_to_be_deleted.role_id
+            ));
+        }
 
-    let deleted_user_role = if user_roles.len() > 1 {
+        if deletion_requestor_role_info.get_entity_type() < target_role_info.get_entity_type() {
+            return Err(report!(UserErrors::InvalidDeleteOperation)).attach_printable(format!(
+                "Invalid operation, deletion requestor = {} cannot delete target = {}",
+                deletion_requestor_role_info.get_entity_type(),
+                target_role_info.get_entity_type()
+            ));
+        }
+
+        user_role_deleted_flag = true;
         state
             .store
-            .delete_user_role_by_user_id_merchant_id(
+            .delete_user_role_by_user_id_and_lineage(
                 user_from_db.get_user_id(),
+                &user_from_token.org_id,
                 &user_from_token.merchant_id,
+                user_from_token.profile_id.as_ref(),
                 UserRoleVersion::V1,
             )
             .await
             .change_context(UserErrors::InternalServerError)
-            .attach_printable("Error while deleting user role")?
-    } else {
+            .attach_printable("Error while deleting user role")?;
+    }
+
+    if !user_role_deleted_flag {
+        return Err(report!(UserErrors::InvalidDeleteOperation))
+            .attach_printable("User is not associated with the merchant");
+    }
+
+    // Check if user has any more role associations
+    let user_roles_v2 = state
+        .store
+        .list_user_roles_by_user_id(user_from_db.get_user_id(), UserRoleVersion::V2)
+        .await
+        .change_context(UserErrors::InternalServerError)?;
+
+    let user_roles_v1 = state
+        .store
+        .list_user_roles_by_user_id(user_from_db.get_user_id(), UserRoleVersion::V1)
+        .await
+        .change_context(UserErrors::InternalServerError)?;
+
+    // If user has no more role associated with him then deleting user
+    if user_roles_v2.is_empty() && user_roles_v1.is_empty() {
         state
             .global_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,
-                UserRoleVersion::V1,
-            )
-            .await
-            .change_context(UserErrors::InternalServerError)
-            .attach_printable("Error while deleting user role")?
-    };
-
-    auth::blacklist::insert_user_in_blacklist(&state, &deleted_user_role.user_id).await?;
+    auth::blacklist::insert_user_in_blacklist(&state, user_from_db.get_user_id()).await?;
     Ok(ApplicationResponse::StatusOk)
 }
diff --git a/crates/router/src/db/kafka_store.rs b/crates/router/src/db/kafka_store.rs
index 31b9ffcd7f8..183722590c3 100644
--- a/crates/router/src/db/kafka_store.rs
+++ b/crates/router/src/db/kafka_store.rs
@@ -2645,24 +2645,51 @@ impl UserRoleInterface for KafkaStore {
             .await
     }
 
-    async fn delete_user_role_by_user_id_merchant_id(
+    async fn list_user_roles_by_user_id(
+        &self,
+        user_id: &str,
+        version: enums::UserRoleVersion,
+    ) -> CustomResult<Vec<user_storage::UserRole>, errors::StorageError> {
+        self.diesel_store
+            .list_user_roles_by_user_id(user_id, version)
+            .await
+    }
+
+    async fn find_user_role_by_user_id_and_lineage(
         &self,
         user_id: &str,
+        org_id: &id_type::OrganizationId,
         merchant_id: &id_type::MerchantId,
+        profile_id: Option<&String>,
         version: enums::UserRoleVersion,
-    ) -> CustomResult<user_storage::UserRole, errors::StorageError> {
+    ) -> CustomResult<storage::UserRole, errors::StorageError> {
         self.diesel_store
-            .delete_user_role_by_user_id_merchant_id(user_id, merchant_id, version)
+            .find_user_role_by_user_id_and_lineage(
+                user_id,
+                org_id,
+                merchant_id,
+                profile_id,
+                version,
+            )
             .await
     }
 
-    async fn list_user_roles_by_user_id(
+    async fn delete_user_role_by_user_id_and_lineage(
         &self,
         user_id: &str,
+        org_id: &id_type::OrganizationId,
+        merchant_id: &id_type::MerchantId,
+        profile_id: Option<&String>,
         version: enums::UserRoleVersion,
-    ) -> CustomResult<Vec<user_storage::UserRole>, errors::StorageError> {
+    ) -> CustomResult<storage::UserRole, errors::StorageError> {
         self.diesel_store
-            .list_user_roles_by_user_id(user_id, version)
+            .delete_user_role_by_user_id_and_lineage(
+                user_id,
+                org_id,
+                merchant_id,
+                profile_id,
+                version,
+            )
             .await
     }
 
diff --git a/crates/router/src/db/user_role.rs b/crates/router/src/db/user_role.rs
index 203860728c0..05f37d0ee72 100644
--- a/crates/router/src/db/user_role.rs
+++ b/crates/router/src/db/user_role.rs
@@ -49,24 +49,35 @@ pub trait UserRoleInterface {
         version: enums::UserRoleVersion,
     ) -> CustomResult<Vec<storage::UserRole>, errors::StorageError>;
 
-    async fn delete_user_role_by_user_id_merchant_id(
+    async fn list_user_roles_by_user_id(
         &self,
         user_id: &str,
+        version: enums::UserRoleVersion,
+    ) -> CustomResult<Vec<storage::UserRole>, errors::StorageError>;
+
+    async fn list_user_roles_by_merchant_id(
+        &self,
         merchant_id: &id_type::MerchantId,
         version: enums::UserRoleVersion,
-    ) -> CustomResult<storage::UserRole, errors::StorageError>;
+    ) -> CustomResult<Vec<storage::UserRole>, errors::StorageError>;
 
-    async fn list_user_roles_by_user_id(
+    async fn find_user_role_by_user_id_and_lineage(
         &self,
         user_id: &str,
+        org_id: &id_type::OrganizationId,
+        merchant_id: &id_type::MerchantId,
+        profile_id: Option<&String>,
         version: enums::UserRoleVersion,
-    ) -> CustomResult<Vec<storage::UserRole>, errors::StorageError>;
+    ) -> CustomResult<storage::UserRole, errors::StorageError>;
 
-    async fn list_user_roles_by_merchant_id(
+    async fn delete_user_role_by_user_id_and_lineage(
         &self,
+        user_id: &str,
+        org_id: &id_type::OrganizationId,
         merchant_id: &id_type::MerchantId,
+        profile_id: Option<&String>,
         version: enums::UserRoleVersion,
-    ) -> CustomResult<Vec<storage::UserRole>, errors::StorageError>;
+    ) -> CustomResult<storage::UserRole, errors::StorageError>;
 
     async fn transfer_org_ownership_between_users(
         &self,
@@ -161,25 +172,6 @@ impl UserRoleInterface for Store {
         .map_err(|error| report!(errors::StorageError::from(error)))
     }
 
-    #[instrument(skip_all)]
-    async fn delete_user_role_by_user_id_merchant_id(
-        &self,
-        user_id: &str,
-        merchant_id: &id_type::MerchantId,
-        version: enums::UserRoleVersion,
-    ) -> CustomResult<storage::UserRole, errors::StorageError> {
-        let conn = connection::pg_connection_write(self).await?;
-
-        storage::UserRole::delete_by_user_id_merchant_id(
-            &conn,
-            user_id.to_owned(),
-            merchant_id.to_owned(),
-            version,
-        )
-        .await
-        .map_err(|error| report!(errors::StorageError::from(error)))
-    }
-
     #[instrument(skip_all)]
     async fn list_user_roles_by_user_id(
         &self,
@@ -204,6 +196,50 @@ impl UserRoleInterface for Store {
             .map_err(|error| report!(errors::StorageError::from(error)))
     }
 
+    #[instrument(skip_all)]
+    async fn find_user_role_by_user_id_and_lineage(
+        &self,
+        user_id: &str,
+        org_id: &id_type::OrganizationId,
+        merchant_id: &id_type::MerchantId,
+        profile_id: Option<&String>,
+        version: enums::UserRoleVersion,
+    ) -> CustomResult<storage::UserRole, errors::StorageError> {
+        let conn = connection::pg_connection_write(self).await?;
+        storage::UserRole::find_by_user_id_org_id_merchant_id_profile_id(
+            &conn,
+            user_id.to_owned(),
+            org_id.to_owned(),
+            merchant_id.to_owned(),
+            profile_id.cloned(),
+            version,
+        )
+        .await
+        .map_err(|error| report!(errors::StorageError::from(error)))
+    }
+
+    #[instrument(skip_all)]
+    async fn delete_user_role_by_user_id_and_lineage(
+        &self,
+        user_id: &str,
+        org_id: &id_type::OrganizationId,
+        merchant_id: &id_type::MerchantId,
+        profile_id: Option<&String>,
+        version: enums::UserRoleVersion,
+    ) -> CustomResult<storage::UserRole, errors::StorageError> {
+        let conn = connection::pg_connection_write(self).await?;
+        storage::UserRole::delete_by_user_id_org_id_merchant_id_profile_id(
+            &conn,
+            user_id.to_owned(),
+            org_id.to_owned(),
+            merchant_id.to_owned(),
+            profile_id.cloned(),
+            version,
+        )
+        .await
+        .map_err(|error| report!(errors::StorageError::from(error)))
+    }
+
     #[instrument(skip_all)]
     async fn transfer_org_ownership_between_users(
         &self,
@@ -565,32 +601,6 @@ impl UserRoleInterface for MockDb {
         Ok(())
     }
 
-    async fn delete_user_role_by_user_id_merchant_id(
-        &self,
-        user_id: &str,
-        merchant_id: &id_type::MerchantId,
-        version: enums::UserRoleVersion,
-    ) -> CustomResult<storage::UserRole, errors::StorageError> {
-        let mut user_roles = self.user_roles.lock().await;
-
-        let index = user_roles.iter().position(|role| {
-            role.user_id == user_id
-                && role.version == version
-                && match role.merchant_id {
-                    Some(ref mid) => mid == merchant_id,
-                    None => false,
-                }
-        });
-
-        match index {
-            Some(idx) => Ok(user_roles.remove(idx)),
-            None => Err(errors::StorageError::ValueNotFound(
-                "Cannot find user role to delete".to_string(),
-            )
-            .into()),
-        }
-    }
-
     async fn list_user_roles_by_user_id(
         &self,
         user_id: &str,
@@ -634,4 +644,83 @@ impl UserRoleInterface for MockDb {
 
         Ok(filtered_roles)
     }
+
+    async fn find_user_role_by_user_id_and_lineage(
+        &self,
+        user_id: &str,
+        org_id: &id_type::OrganizationId,
+        merchant_id: &id_type::MerchantId,
+        profile_id: Option<&String>,
+        version: enums::UserRoleVersion,
+    ) -> CustomResult<storage::UserRole, errors::StorageError> {
+        let user_roles = self.user_roles.lock().await;
+
+        for user_role in user_roles.iter() {
+            let org_level_check = user_role.org_id.as_ref() == Some(org_id)
+                && user_role.merchant_id.is_none()
+                && user_role.profile_id.is_none();
+
+            let merchant_level_check = user_role.org_id.as_ref() == Some(org_id)
+                && user_role.merchant_id.as_ref() == Some(merchant_id)
+                && user_role.profile_id.is_none();
+
+            let profile_level_check = user_role.org_id.as_ref() == Some(org_id)
+                && user_role.merchant_id.as_ref() == Some(merchant_id)
+                && user_role.profile_id.as_ref() == profile_id;
+
+            // Check if any condition matches and the version matches
+            if user_role.user_id == user_id
+                && (org_level_check || merchant_level_check || profile_level_check)
+                && user_role.version == version
+            {
+                return Ok(user_role.clone());
+            }
+        }
+
+        Err(errors::StorageError::ValueNotFound(format!(
+            "No user role available for user_id = {} in the current token hierarchy",
+            user_id
+        ))
+        .into())
+    }
+
+    async fn delete_user_role_by_user_id_and_lineage(
+        &self,
+        user_id: &str,
+        org_id: &id_type::OrganizationId,
+        merchant_id: &id_type::MerchantId,
+        profile_id: Option<&String>,
+        version: enums::UserRoleVersion,
+    ) -> CustomResult<storage::UserRole, errors::StorageError> {
+        let mut user_roles = self.user_roles.lock().await;
+
+        // Find the position of the user role to delete
+        let index = user_roles.iter().position(|role| {
+            let org_level_check = role.org_id.as_ref() == Some(org_id)
+                && role.merchant_id.is_none()
+                && role.profile_id.is_none();
+
+            let merchant_level_check = role.org_id.as_ref() == Some(org_id)
+                && role.merchant_id.as_ref() == Some(merchant_id)
+                && role.profile_id.is_none();
+
+            let profile_level_check = role.org_id.as_ref() == Some(org_id)
+                && role.merchant_id.as_ref() == Some(merchant_id)
+                && role.profile_id.as_ref() == profile_id;
+
+            // Check if the user role matches the conditions and the version matches
+            role.user_id == user_id
+                && (org_level_check || merchant_level_check || profile_level_check)
+                && role.version == version
+        });
+
+        // Remove and return the user role if found
+        match index {
+            Some(idx) => Ok(user_roles.remove(idx)),
+            None => Err(errors::StorageError::ValueNotFound(
+                "Cannot find user role to delete".to_string(),
+            )
+            .into()),
+        }
+    }
 }

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

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

## Description
- Add support to delete profile level users
- Delete is backward compatible
- Delete to support both V1 and V2 operations accordingly

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


## How did you test it?
Use the curl to delete user role
```
curl --location --request DELETE 'http://localhost:8080/user/user/delete' \
--header 'Authorization: Bearer JWT' \
--header 'Content-Type: application/json' \
--data-raw '{
  "email": "email_to_delete"
}'
```


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