feat(themes): Create user APIs for managing themes (#8387)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Kanika Bansal
2025-07-28 13:02:37 +05:30
committed by GitHub
parent b30a7b8093
commit 20049d52fa
18 changed files with 844 additions and 34 deletions

View File

@ -4,7 +4,8 @@ use common_utils::events::{ApiEventMetric, ApiEventsType};
use crate::user::sample_data::SampleDataRequest;
#[cfg(feature = "control_center_theme")]
use crate::user::theme::{
CreateThemeRequest, GetThemeResponse, UpdateThemeRequest, UploadFileRequest,
CreateThemeRequest, CreateUserThemeRequest, GetThemeResponse, UpdateThemeRequest,
UploadFileRequest,
};
use crate::user::{
dashboard_metadata::{
@ -83,6 +84,7 @@ common_utils::impl_api_event_type!(
GetThemeResponse,
UploadFileRequest,
CreateThemeRequest,
CreateUserThemeRequest,
UpdateThemeRequest
)
);

View File

@ -41,6 +41,14 @@ pub struct CreateThemeRequest {
pub email_config: Option<EmailThemeConfig>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct CreateUserThemeRequest {
pub entity_type: EntityType,
pub theme_name: String,
pub theme_data: ThemeData,
pub email_config: Option<EmailThemeConfig>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct UpdateThemeRequest {
pub theme_data: Option<ThemeData>,
@ -137,3 +145,9 @@ struct Urls {
favicon_url: Option<String>,
logo_url: Option<String>,
}
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "snake_case")]
pub struct EntityTypeQueryParam {
pub entity_type: EntityType,
}

View File

@ -7534,6 +7534,8 @@ pub enum PermissionGroup {
ReconOpsView,
ReconOpsManage,
InternalManage,
ThemeView,
ThemeManage,
}
#[derive(Clone, Debug, serde::Serialize, PartialEq, Eq, Hash, strum::EnumIter)]
@ -7547,6 +7549,7 @@ pub enum ParentGroup {
ReconReports,
Account,
Internal,
Theme,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, serde::Serialize)]
@ -7577,6 +7580,7 @@ pub enum Resource {
ReconConfig,
RevenueRecovery,
InternalConnector,
Theme,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, serde::Serialize, Hash)]

View File

@ -9,7 +9,6 @@ crate::impl_id_type_methods!(ProfileId, "profile_id");
// This is to display the `ProfileId` as ProfileId(abcd)
crate::impl_debug_id_type!(ProfileId);
crate::impl_try_from_cow_str_id_type!(ProfileId, "profile_id");
crate::impl_generate_id_id_type!(ProfileId, "pro");
crate::impl_serializable_secret_id_type!(ProfileId);
crate::impl_queryable_id_type!(ProfileId);

View File

@ -8,7 +8,7 @@ use crate::{
/// Enum for having all the required lineage for every level.
/// Currently being used for theme related APIs and queries.
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(tag = "entity_type", rename_all = "snake_case")]
pub enum ThemeLineage {
/// Tenant lineage variant

View File

@ -77,6 +77,48 @@ impl Theme {
}
}
/// Matches all themes that belong to the specified hierarchy level or below
fn lineage_hierarchy_filter(
lineage: ThemeLineage,
) -> Box<
dyn diesel::BoxableExpression<<Self as HasTable>::Table, Pg, SqlType = Nullable<Bool>>
+ 'static,
> {
match lineage {
ThemeLineage::Tenant { tenant_id } => Box::new(dsl::tenant_id.eq(tenant_id).nullable()),
ThemeLineage::Organization { tenant_id, org_id } => Box::new(
dsl::tenant_id
.eq(tenant_id)
.and(dsl::org_id.eq(org_id))
.nullable(),
),
ThemeLineage::Merchant {
tenant_id,
org_id,
merchant_id,
} => Box::new(
dsl::tenant_id
.eq(tenant_id)
.and(dsl::org_id.eq(org_id))
.and(dsl::merchant_id.eq(merchant_id))
.nullable(),
),
ThemeLineage::Profile {
tenant_id,
org_id,
merchant_id,
profile_id,
} => Box::new(
dsl::tenant_id
.eq(tenant_id)
.and(dsl::org_id.eq(org_id))
.and(dsl::merchant_id.eq(merchant_id))
.and(dsl::profile_id.eq(profile_id))
.nullable(),
),
}
}
pub async fn find_by_theme_id(conn: &PgPooledConn, theme_id: String) -> StorageResult<Self> {
generics::generic_find_one::<<Self as HasTable>::Table, _, _>(
conn,
@ -161,4 +203,35 @@ impl Theme {
)
.await
}
pub async fn delete_by_theme_id(conn: &PgPooledConn, theme_id: String) -> StorageResult<Self> {
generics::generic_delete_one_with_result::<<Self as HasTable>::Table, _, _>(
conn,
dsl::theme_id.eq(theme_id),
)
.await
}
/// Finds all themes that match the specified lineage hierarchy.
pub async fn find_all_by_lineage_hierarchy(
conn: &PgPooledConn,
lineage: ThemeLineage,
) -> StorageResult<Vec<Self>> {
let filter = Self::lineage_hierarchy_filter(lineage);
let query = <Self as HasTable>::table().filter(filter).into_boxed();
logger::debug!(query = %debug_query::<Pg,_>(&query).to_string());
match track_database_call::<Self, _, _>(
query.get_results_async(conn),
DatabaseOperation::Filter,
)
.await
{
Ok(themes) => Ok(themes),
Err(err) => match err {
DieselError::NotFound => Err(report!(err)).change_context(DatabaseError::NotFound),
_ => Err(report!(err)).change_context(DatabaseError::Others),
},
}
}
}

View File

@ -1,4 +1,5 @@
use api_models::user::theme as theme_api;
use common_enums::EntityType;
use common_utils::{
ext_traits::{ByteSliceExt, Encode},
types::user::ThemeLineage,
@ -13,9 +14,11 @@ use uuid::Uuid;
use crate::{
core::errors::{StorageErrorExt, UserErrors, UserResponse},
routes::SessionState,
services::authentication::UserFromToken,
utils::user::theme as theme_utils,
};
// TODO: To be deprecated
pub async fn get_theme_using_lineage(
state: SessionState,
lineage: ThemeLineage,
@ -50,6 +53,7 @@ pub async fn get_theme_using_lineage(
}))
}
// TODO: To be deprecated
pub async fn get_theme_using_theme_id(
state: SessionState,
theme_id: String,
@ -84,6 +88,7 @@ pub async fn get_theme_using_theme_id(
}))
}
// TODO: To be deprecated
pub async fn upload_file_to_theme_storage(
state: SessionState,
theme_id: String,
@ -105,6 +110,7 @@ pub async fn upload_file_to_theme_storage(
Ok(ApplicationResponse::StatusOk)
}
// TODO: To be deprecated
pub async fn create_theme(
state: SessionState,
request: theme_api::CreateThemeRequest,
@ -166,6 +172,7 @@ pub async fn create_theme(
}))
}
// TODO: To be deprecated
pub async fn update_theme(
state: SessionState,
theme_id: String,
@ -223,14 +230,11 @@ pub async fn update_theme(
}))
}
pub async fn delete_theme(
state: SessionState,
theme_id: String,
lineage: ThemeLineage,
) -> UserResponse<()> {
// TODO: To be deprecated
pub async fn delete_theme(state: SessionState, theme_id: String) -> UserResponse<()> {
state
.store
.delete_theme_by_lineage_and_theme_id(theme_id.clone(), lineage)
.delete_theme_by_theme_id(theme_id.clone())
.await
.to_not_found_response(UserErrors::ThemeNotFound)?;
@ -240,3 +244,331 @@ pub async fn delete_theme(
Ok(ApplicationResponse::StatusOk)
}
pub async fn create_user_theme(
state: SessionState,
user_from_token: UserFromToken,
request: theme_api::CreateUserThemeRequest,
) -> UserResponse<theme_api::GetThemeResponse> {
let email_config = if cfg!(feature = "email") {
request.email_config.ok_or(UserErrors::MissingEmailConfig)?
} else {
request
.email_config
.unwrap_or(state.conf.theme.email_config.clone())
};
let lineage = theme_utils::get_theme_lineage_from_user_token(
&user_from_token,
&state,
&request.entity_type,
)
.await?;
let new_theme = ThemeNew::new(
Uuid::new_v4().to_string(),
request.theme_name,
lineage,
email_config,
);
let db_theme = state
.store
.insert_theme(new_theme)
.await
.to_duplicate_response(UserErrors::ThemeAlreadyExists)?;
theme_utils::upload_file_to_theme_bucket(
&state,
&theme_utils::get_theme_file_key(&db_theme.theme_id),
request
.theme_data
.encode_to_vec()
.change_context(UserErrors::InternalServerError)?,
)
.await?;
let file = theme_utils::retrieve_file_from_theme_bucket(
&state,
&theme_utils::get_theme_file_key(&db_theme.theme_id),
)
.await?;
let parsed_data = file
.to_bytes()
.parse_struct("ThemeData")
.change_context(UserErrors::InternalServerError)?;
Ok(ApplicationResponse::Json(theme_api::GetThemeResponse {
email_config: db_theme.email_config(),
theme_id: db_theme.theme_id,
entity_type: db_theme.entity_type,
tenant_id: db_theme.tenant_id,
org_id: db_theme.org_id,
merchant_id: db_theme.merchant_id,
profile_id: db_theme.profile_id,
theme_name: db_theme.theme_name,
theme_data: parsed_data,
}))
}
pub async fn delete_user_theme(
state: SessionState,
user_from_token: UserFromToken,
theme_id: String,
) -> UserResponse<()> {
let db_theme = state
.store
.find_theme_by_theme_id(theme_id.clone())
.await
.to_not_found_response(UserErrors::ThemeNotFound)?;
let user_role_info = user_from_token
.get_role_info_from_db(&state)
.await
.attach_printable("Invalid role_id in JWT")?;
let user_entity_type = user_role_info.get_entity_type();
theme_utils::can_user_access_theme(&user_from_token, &user_entity_type, &db_theme).await?;
state
.store
.delete_theme_by_theme_id(theme_id.clone())
.await
.to_not_found_response(UserErrors::ThemeNotFound)?;
// TODO (#6717): Delete theme folder from the theme storage.
// Currently there is no simple or easy way to delete a whole folder from S3.
// So, we are not deleting the theme folder from the theme storage.
Ok(ApplicationResponse::StatusOk)
}
pub async fn update_user_theme(
state: SessionState,
theme_id: String,
user_from_token: UserFromToken,
request: theme_api::UpdateThemeRequest,
) -> UserResponse<theme_api::GetThemeResponse> {
let db_theme = state
.store
.find_theme_by_theme_id(theme_id.clone())
.await
.to_not_found_response(UserErrors::ThemeNotFound)?;
let user_role_info = user_from_token
.get_role_info_from_db(&state)
.await
.attach_printable("Invalid role_id in JWT")?;
let user_entity_type = user_role_info.get_entity_type();
theme_utils::can_user_access_theme(&user_from_token, &user_entity_type, &db_theme).await?;
let db_theme = match request.email_config {
Some(email_config) => {
let theme_update = ThemeUpdate::EmailConfig { email_config };
state
.store
.update_theme_by_theme_id(theme_id.clone(), theme_update)
.await
.change_context(UserErrors::InternalServerError)?
}
None => db_theme,
};
if let Some(theme_data) = request.theme_data {
theme_utils::upload_file_to_theme_bucket(
&state,
&theme_utils::get_theme_file_key(&db_theme.theme_id),
theme_data
.encode_to_vec()
.change_context(UserErrors::InternalServerError)
.attach_printable("Failed to parse ThemeData")?,
)
.await?;
}
let file = theme_utils::retrieve_file_from_theme_bucket(
&state,
&theme_utils::get_theme_file_key(&db_theme.theme_id),
)
.await?;
let parsed_data = file
.to_bytes()
.parse_struct("ThemeData")
.change_context(UserErrors::InternalServerError)?;
Ok(ApplicationResponse::Json(theme_api::GetThemeResponse {
email_config: db_theme.email_config(),
theme_id: db_theme.theme_id,
entity_type: db_theme.entity_type,
tenant_id: db_theme.tenant_id,
org_id: db_theme.org_id,
merchant_id: db_theme.merchant_id,
profile_id: db_theme.profile_id,
theme_name: db_theme.theme_name,
theme_data: parsed_data,
}))
}
pub async fn upload_file_to_user_theme_storage(
state: SessionState,
theme_id: String,
user_from_token: UserFromToken,
request: theme_api::UploadFileRequest,
) -> UserResponse<()> {
let db_theme = state
.store
.find_theme_by_theme_id(theme_id)
.await
.to_not_found_response(UserErrors::ThemeNotFound)?;
let user_role_info = user_from_token
.get_role_info_from_db(&state)
.await
.attach_printable("Invalid role_id in JWT")?;
let user_entity_type = user_role_info.get_entity_type();
theme_utils::can_user_access_theme(&user_from_token, &user_entity_type, &db_theme).await?;
theme_utils::upload_file_to_theme_bucket(
&state,
&theme_utils::get_specific_file_key(&db_theme.theme_id, &request.asset_name),
request.asset_data.expose(),
)
.await?;
Ok(ApplicationResponse::StatusOk)
}
pub async fn list_all_themes_in_lineage(
state: SessionState,
user: UserFromToken,
entity_type: EntityType,
) -> UserResponse<Vec<theme_api::GetThemeResponse>> {
let lineage =
theme_utils::get_theme_lineage_from_user_token(&user, &state, &entity_type).await?;
let db_themes = state
.store
.list_themes_at_and_under_lineage(lineage)
.await
.change_context(UserErrors::InternalServerError)?;
let mut themes = Vec::new();
for theme in db_themes {
match theme_utils::retrieve_file_from_theme_bucket(
&state,
&theme_utils::get_theme_file_key(&theme.theme_id),
)
.await
{
Ok(file) => {
match file
.to_bytes()
.parse_struct("ThemeData")
.change_context(UserErrors::InternalServerError)
{
Ok(parsed_data) => {
themes.push(theme_api::GetThemeResponse {
email_config: theme.email_config(),
theme_id: theme.theme_id,
theme_name: theme.theme_name,
entity_type: theme.entity_type,
tenant_id: theme.tenant_id,
org_id: theme.org_id,
merchant_id: theme.merchant_id,
profile_id: theme.profile_id,
theme_data: parsed_data,
});
}
Err(_) => {
return Err(UserErrors::ErrorRetrievingFile.into());
}
}
}
Err(_) => {
return Err(UserErrors::ErrorRetrievingFile.into());
}
}
}
Ok(ApplicationResponse::Json(themes))
}
pub async fn get_user_theme_using_theme_id(
state: SessionState,
user_from_token: UserFromToken,
theme_id: String,
) -> UserResponse<theme_api::GetThemeResponse> {
let db_theme = state
.store
.find_theme_by_theme_id(theme_id.clone())
.await
.to_not_found_response(UserErrors::ThemeNotFound)?;
let user_role_info = user_from_token
.get_role_info_from_db(&state)
.await
.attach_printable("Invalid role_id in JWT")?;
let user_role_entity = user_role_info.get_entity_type();
theme_utils::can_user_access_theme(&user_from_token, &user_role_entity, &db_theme).await?;
let file = theme_utils::retrieve_file_from_theme_bucket(
&state,
&theme_utils::get_theme_file_key(&theme_id),
)
.await?;
let parsed_data = file
.to_bytes()
.parse_struct("ThemeData")
.change_context(UserErrors::InternalServerError)?;
Ok(ApplicationResponse::Json(theme_api::GetThemeResponse {
email_config: db_theme.email_config(),
theme_id: db_theme.theme_id,
theme_name: db_theme.theme_name,
entity_type: db_theme.entity_type,
tenant_id: db_theme.tenant_id,
org_id: db_theme.org_id,
merchant_id: db_theme.merchant_id,
profile_id: db_theme.profile_id,
theme_data: parsed_data,
}))
}
pub async fn get_user_theme_using_lineage(
state: SessionState,
user_from_token: UserFromToken,
entity_type: EntityType,
) -> UserResponse<theme_api::GetThemeResponse> {
let lineage =
theme_utils::get_theme_lineage_from_user_token(&user_from_token, &state, &entity_type)
.await?;
let theme = state
.store
.find_theme_by_lineage(lineage)
.await
.to_not_found_response(UserErrors::ThemeNotFound)?;
let file = theme_utils::retrieve_file_from_theme_bucket(
&state,
&theme_utils::get_theme_file_key(&theme.theme_id),
)
.await?;
let parsed_data = file
.to_bytes()
.parse_struct("ThemeData")
.change_context(UserErrors::InternalServerError)?;
Ok(ApplicationResponse::Json(theme_api::GetThemeResponse {
email_config: theme.email_config(),
theme_id: theme.theme_id,
theme_name: theme.theme_name,
entity_type: theme.entity_type,
tenant_id: theme.tenant_id,
org_id: theme.org_id,
merchant_id: theme.merchant_id,
profile_id: theme.profile_id,
theme_data: parsed_data,
}))
}

View File

@ -4152,13 +4152,19 @@ impl ThemeInterface for KafkaStore {
.await
}
async fn delete_theme_by_lineage_and_theme_id(
async fn delete_theme_by_theme_id(
&self,
theme_id: String,
lineage: ThemeLineage,
) -> CustomResult<storage::theme::Theme, errors::StorageError> {
self.diesel_store.delete_theme_by_theme_id(theme_id).await
}
async fn list_themes_at_and_under_lineage(
&self,
lineage: ThemeLineage,
) -> CustomResult<Vec<storage::theme::Theme>, errors::StorageError> {
self.diesel_store
.delete_theme_by_lineage_and_theme_id(theme_id, lineage)
.list_themes_at_and_under_lineage(lineage)
.await
}
}

View File

@ -37,11 +37,15 @@ pub trait ThemeInterface {
theme_update: ThemeUpdate,
) -> CustomResult<storage::Theme, errors::StorageError>;
async fn delete_theme_by_lineage_and_theme_id(
async fn delete_theme_by_theme_id(
&self,
theme_id: String,
lineage: ThemeLineage,
) -> CustomResult<storage::Theme, errors::StorageError>;
async fn list_themes_at_and_under_lineage(
&self,
lineage: ThemeLineage,
) -> CustomResult<Vec<storage::Theme>, errors::StorageError>;
}
#[async_trait::async_trait]
@ -98,13 +102,21 @@ impl ThemeInterface for Store {
.map_err(|error| report!(errors::StorageError::from(error)))
}
async fn delete_theme_by_lineage_and_theme_id(
async fn delete_theme_by_theme_id(
&self,
theme_id: String,
lineage: ThemeLineage,
) -> CustomResult<storage::Theme, errors::StorageError> {
let conn = connection::pg_connection_write(self).await?;
storage::Theme::delete_by_theme_id_and_lineage(&conn, theme_id, lineage)
storage::Theme::delete_by_theme_id(&conn, theme_id)
.await
.map_err(|error| report!(errors::StorageError::from(error)))
}
async fn list_themes_at_and_under_lineage(
&self,
lineage: ThemeLineage,
) -> CustomResult<Vec<storage::Theme>, errors::StorageError> {
let conn = connection::pg_connection_read(self).await?;
storage::Theme::find_all_by_lineage_hierarchy(&conn, lineage)
.await
.map_err(|error| report!(errors::StorageError::from(error)))
}
@ -166,6 +178,57 @@ fn check_theme_with_lineage(theme: &storage::Theme, lineage: &ThemeLineage) -> b
}
}
fn check_theme_belongs_to_lineage_hierarchy(
theme: &storage::Theme,
lineage: &ThemeLineage,
) -> bool {
match lineage {
ThemeLineage::Tenant { tenant_id } => &theme.tenant_id == tenant_id,
ThemeLineage::Organization { tenant_id, org_id } => {
&theme.tenant_id == tenant_id
&& theme
.org_id
.as_ref()
.is_some_and(|org_id_inner| org_id_inner == org_id)
}
ThemeLineage::Merchant {
tenant_id,
org_id,
merchant_id,
} => {
&theme.tenant_id == tenant_id
&& theme
.org_id
.as_ref()
.is_some_and(|org_id_inner| org_id_inner == org_id)
&& theme
.merchant_id
.as_ref()
.is_some_and(|merchant_id_inner| merchant_id_inner == merchant_id)
}
ThemeLineage::Profile {
tenant_id,
org_id,
merchant_id,
profile_id,
} => {
&theme.tenant_id == tenant_id
&& theme
.org_id
.as_ref()
.is_some_and(|org_id_inner| org_id_inner == org_id)
&& theme
.merchant_id
.as_ref()
.is_some_and(|merchant_id_inner| merchant_id_inner == merchant_id)
&& theme
.profile_id
.as_ref()
.is_some_and(|profile_id_inner| profile_id_inner == profile_id)
}
}
}
#[async_trait::async_trait]
impl ThemeInterface for MockDb {
async fn insert_theme(
@ -297,23 +360,32 @@ impl ThemeInterface for MockDb {
})
}
async fn delete_theme_by_lineage_and_theme_id(
async fn delete_theme_by_theme_id(
&self,
theme_id: String,
lineage: ThemeLineage,
) -> CustomResult<storage::Theme, errors::StorageError> {
let mut themes = self.themes.lock().await;
let index = themes
.iter()
.position(|theme| {
theme.theme_id == theme_id && check_theme_with_lineage(theme, &lineage)
})
.position(|theme| theme.theme_id == theme_id)
.ok_or(errors::StorageError::ValueNotFound(format!(
"Theme with id {theme_id} and lineage {lineage:?} not found",
"Theme with id {theme_id} not found"
)))?;
let theme = themes.remove(index);
Ok(theme)
}
async fn list_themes_at_and_under_lineage(
&self,
lineage: ThemeLineage,
) -> CustomResult<Vec<storage::Theme>, errors::StorageError> {
let themes = self.themes.lock().await;
let matching_themes: Vec<storage::Theme> = themes
.iter()
.filter(|theme| check_theme_belongs_to_lineage_hierarchy(theme, &lineage))
.cloned()
.collect();
Ok(matching_themes)
}
}

View File

@ -2663,9 +2663,10 @@ impl User {
.route(web::delete().to(user::delete_sample_data)),
)
}
// Admin Theme
// TODO: To be deprecated
route = route.service(
web::scope("/theme")
web::scope("/admin/theme")
.service(
web::resource("")
.route(web::get().to(user::theme::get_theme_using_lineage))
@ -2679,7 +2680,26 @@ impl User {
.route(web::delete().to(user::theme::delete_theme)),
),
);
// User Theme
route = route.service(
web::scope("/theme")
.service(
web::resource("")
.route(web::post().to(user::theme::create_user_theme))
.route(web::get().to(user::theme::get_user_theme_using_lineage)),
)
.service(
web::resource("/list")
.route(web::get().to(user::theme::list_all_themes_in_lineage)),
)
.service(
web::resource("/{theme_id}")
.route(web::get().to(user::theme::get_user_theme_using_theme_id))
.route(web::put().to(user::theme::update_user_theme))
.route(web::post().to(user::theme::upload_file_to_user_theme_storage))
.route(web::delete().to(user::theme::delete_user_theme)),
),
);
route
}
}

View File

@ -302,6 +302,13 @@ impl From<Flow> for ApiIdentifier {
| Flow::CreateTheme
| Flow::UpdateTheme
| Flow::DeleteTheme
| Flow::CreateUserTheme
| Flow::UpdateUserTheme
| Flow::DeleteUserTheme
| Flow::GetUserThemeUsingThemeId
| Flow::UploadFileToUserThemeStorage
| Flow::GetUserThemeUsingLineage
| Flow::ListAllThemesInLineage
| Flow::CloneConnector => Self::User,
Flow::GetDataFromHyperswitchAiFlow => Self::AiWorkflow,

View File

@ -8,7 +8,7 @@ use router_env::Flow;
use crate::{
core::{api_locking, user::theme as theme_core},
routes::AppState,
services::{api, authentication as auth},
services::{api, authentication as auth, authorization::permissions::Permission},
};
pub async fn get_theme_using_lineage(
@ -124,20 +124,190 @@ pub async fn delete_theme(
state: web::Data<AppState>,
req: HttpRequest,
path: web::Path<String>,
query: web::Query<ThemeLineage>,
) -> HttpResponse {
let flow = Flow::DeleteTheme;
let theme_id = path.into_inner();
let lineage = query.into_inner();
Box::pin(api::server_wrap(
flow,
state,
&req,
lineage,
|state, _, lineage, _| theme_core::delete_theme(state, theme_id.clone(), lineage),
theme_id,
|state, _, theme_id, _| theme_core::delete_theme(state, theme_id),
&auth::AdminApiAuth,
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn create_user_theme(
state: web::Data<AppState>,
req: HttpRequest,
payload: web::Json<theme_api::CreateUserThemeRequest>,
) -> HttpResponse {
let flow = Flow::CreateUserTheme;
let payload = payload.into_inner();
Box::pin(api::server_wrap(
flow,
state,
&req,
payload,
|state, user: auth::UserFromToken, payload, _| {
theme_core::create_user_theme(state, user, payload)
},
&auth::JWTAuth {
permission: Permission::OrganizationThemeWrite,
},
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn get_user_theme_using_theme_id(
state: web::Data<AppState>,
req: HttpRequest,
path: web::Path<String>,
) -> HttpResponse {
let flow = Flow::GetUserThemeUsingThemeId;
let payload = path.into_inner();
Box::pin(api::server_wrap(
flow,
state,
&req,
payload,
|state, user: auth::UserFromToken, payload, _| {
theme_core::get_user_theme_using_theme_id(state, user, payload)
},
&auth::JWTAuth {
permission: Permission::OrganizationThemeRead,
},
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn update_user_theme(
state: web::Data<AppState>,
req: HttpRequest,
path: web::Path<String>,
payload: web::Json<theme_api::UpdateThemeRequest>,
) -> HttpResponse {
let flow = Flow::UpdateUserTheme;
let theme_id = path.into_inner();
let payload = payload.into_inner();
Box::pin(api::server_wrap(
flow,
state,
&req,
payload,
|state, user: auth::UserFromToken, payload, _| {
theme_core::update_user_theme(state, theme_id.clone(), user, payload)
},
&auth::JWTAuth {
permission: Permission::OrganizationThemeWrite,
},
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn delete_user_theme(
state: web::Data<AppState>,
req: HttpRequest,
path: web::Path<String>,
) -> HttpResponse {
let flow = Flow::DeleteUserTheme;
let theme_id = path.into_inner();
Box::pin(api::server_wrap(
flow,
state,
&req,
theme_id,
|state, user: auth::UserFromToken, theme_id, _| {
theme_core::delete_user_theme(state, user, theme_id)
},
&auth::JWTAuth {
permission: Permission::OrganizationThemeWrite,
},
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn upload_file_to_user_theme_storage(
state: web::Data<AppState>,
req: HttpRequest,
path: web::Path<String>,
MultipartForm(payload): MultipartForm<theme_api::UploadFileAssetData>,
) -> HttpResponse {
let flow = Flow::UploadFileToUserThemeStorage;
let theme_id = path.into_inner();
let payload = theme_api::UploadFileRequest {
asset_name: payload.asset_name.into_inner(),
asset_data: Secret::new(payload.asset_data.data.to_vec()),
};
Box::pin(api::server_wrap(
flow,
state,
&req,
payload,
|state, user: auth::UserFromToken, payload, _| {
theme_core::upload_file_to_user_theme_storage(state, theme_id.clone(), user, payload)
},
&auth::JWTAuth {
permission: Permission::OrganizationThemeWrite,
},
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn list_all_themes_in_lineage(
state: web::Data<AppState>,
req: HttpRequest,
query: web::Query<theme_api::EntityTypeQueryParam>,
) -> HttpResponse {
let flow = Flow::ListAllThemesInLineage;
let entity_type = query.into_inner().entity_type;
Box::pin(api::server_wrap(
flow,
state,
&req,
(),
|state, user: auth::UserFromToken, _payload, _| {
theme_core::list_all_themes_in_lineage(state, user, entity_type)
},
&auth::JWTAuth {
permission: Permission::OrganizationThemeRead,
},
api_locking::LockAction::NotApplicable,
))
.await
}
pub async fn get_user_theme_using_lineage(
state: web::Data<AppState>,
req: HttpRequest,
query: web::Query<theme_api::EntityTypeQueryParam>,
) -> HttpResponse {
let flow = Flow::GetUserThemeUsingLineage;
let entity_type = query.into_inner().entity_type;
Box::pin(api::server_wrap(
flow,
state,
&req,
(),
|state, user: auth::UserFromToken, _payload, _| {
theme_core::get_user_theme_using_lineage(state, user, entity_type)
},
&auth::JWTAuth {
permission: Permission::OrganizationThemeRead,
},
api_locking::LockAction::NotApplicable,
))
.await
}

View File

@ -48,6 +48,8 @@ fn get_group_description(group: PermissionGroup) -> Option<&'static str> {
PermissionGroup::ReconReportsManage => Some("Manage reconciliation reports"),
PermissionGroup::ReconOpsView => Some("View and access all reconciliation operations including reports and analytics"),
PermissionGroup::ReconOpsManage => Some("Manage all reconciliation operations including reports and analytics"),
PermissionGroup::ThemeView => Some("View Themes"),
PermissionGroup::ThemeManage => Some("Manage Themes"),
PermissionGroup::InternalManage => None, // Internal group, no user-facing description
}
}
@ -62,6 +64,7 @@ pub fn get_parent_group_description(group: ParentGroup) -> Option<&'static str>
ParentGroup::Account => Some("Create, modify and delete Merchant Details like api keys, webhooks, etc"),
ParentGroup::ReconOps => Some("View, manage reconciliation operations like upload and process files, run reconciliation etc"),
ParentGroup::ReconReports => Some("View, manage reconciliation reports and analytics"),
ParentGroup::Theme => Some("Manage and view themes for the organization"),
ParentGroup::Internal => None, // Internal group, no user-facing description
}
}

View File

@ -23,7 +23,8 @@ impl PermissionGroupExt for PermissionGroup {
| Self::MerchantDetailsView
| Self::AccountView
| Self::ReconOpsView
| Self::ReconReportsView => PermissionScope::Read,
| Self::ReconReportsView
| Self::ThemeView => PermissionScope::Read,
Self::OperationsManage
| Self::ConnectorsManage
@ -34,7 +35,8 @@ impl PermissionGroupExt for PermissionGroup {
| Self::AccountManage
| Self::ReconOpsManage
| Self::ReconReportsManage
| Self::InternalManage => PermissionScope::Write,
| Self::InternalManage
| Self::ThemeManage => PermissionScope::Write,
}
}
@ -50,6 +52,8 @@ impl PermissionGroupExt for PermissionGroup {
| Self::MerchantDetailsManage
| Self::AccountView
| Self::AccountManage => ParentGroup::Account,
Self::ThemeView | Self::ThemeManage => ParentGroup::Theme,
Self::ReconOpsView | Self::ReconOpsManage => ParentGroup::ReconOps,
Self::ReconReportsView | Self::ReconReportsManage => ParentGroup::ReconReports,
Self::InternalManage => ParentGroup::Internal,
@ -103,6 +107,8 @@ impl PermissionGroupExt for PermissionGroup {
Self::AccountManage => vec![Self::AccountView, Self::AccountManage],
Self::InternalManage => vec![Self::InternalManage],
Self::ThemeView => vec![Self::ThemeView, Self::AccountView],
Self::ThemeManage => vec![Self::ThemeManage, Self::AccountView],
}
}
}
@ -127,6 +133,7 @@ impl ParentGroupExt for ParentGroup {
Self::ReconOps => RECON_OPS.to_vec(),
Self::ReconReports => RECON_REPORTS.to_vec(),
Self::Internal => INTERNAL.to_vec(),
Self::Theme => THEME.to_vec(),
}
}
@ -210,3 +217,5 @@ pub static RECON_REPORTS: [Resource; 4] = [
Resource::ReconReports,
Resource::Account,
];
pub static THEME: [Resource; 1] = [Resource::Theme];

View File

@ -102,6 +102,10 @@ generate_permissions! {
InternalConnector: {
scopes: [Write],
entities: [Merchant]
},
Theme: {
scopes: [Read,Write],
entities: [Organization]
}
]
}
@ -137,6 +141,7 @@ pub fn get_resource_name(resource: Resource, entity_type: EntityType) -> Option<
(Resource::Account, EntityType::Merchant) => Some("Merchant Account"),
(Resource::Account, EntityType::Organization) => Some("Organization Account"),
(Resource::Account, EntityType::Tenant) => Some("Tenant Account"),
(Resource::Theme, _) => Some("Themes"),
(Resource::InternalConnector, _) => None,
}
}

View File

@ -150,6 +150,8 @@ pub static PREDEFINED_ROLES: LazyLock<HashMap<&'static str, RoleInfo>> = LazyLoc
PermissionGroup::ReconOpsManage,
PermissionGroup::ReconReportsView,
PermissionGroup::ReconReportsManage,
PermissionGroup::ThemeView,
PermissionGroup::ThemeManage,
],
role_id: common_utils::consts::ROLE_ID_ORGANIZATION_ADMIN.to_string(),
role_name: "organization_admin".to_string(),

View File

@ -224,3 +224,81 @@ pub async fn get_theme_using_optional_theme_id(
}
}
}
pub async fn get_theme_lineage_from_user_token(
user_from_token: &UserFromToken,
state: &SessionState,
request_entity_type: &EntityType,
) -> UserResult<ThemeLineage> {
let tenant_id = user_from_token
.tenant_id
.clone()
.unwrap_or(state.tenant.tenant_id.clone());
let org_id = user_from_token.org_id.clone();
let merchant_id = user_from_token.merchant_id.clone();
let profile_id = user_from_token.profile_id.clone();
Ok(ThemeLineage::new(
*request_entity_type,
tenant_id,
org_id,
merchant_id,
profile_id,
))
}
pub async fn can_user_access_theme(
user: &UserFromToken,
user_entity_type: &EntityType,
theme: &Theme,
) -> UserResult<()> {
if user_entity_type < &theme.entity_type {
return Err(UserErrors::ThemeNotFound.into());
}
match theme.entity_type {
EntityType::Tenant => {
if user.tenant_id.as_ref() == Some(&theme.tenant_id)
&& theme.org_id.is_none()
&& theme.merchant_id.is_none()
&& theme.profile_id.is_none()
{
Ok(())
} else {
Err(UserErrors::ThemeNotFound.into())
}
}
EntityType::Organization => {
if user.tenant_id.as_ref() == Some(&theme.tenant_id)
&& theme.org_id.as_ref() == Some(&user.org_id)
&& theme.merchant_id.is_none()
&& theme.profile_id.is_none()
{
Ok(())
} else {
Err(UserErrors::ThemeNotFound.into())
}
}
EntityType::Merchant => {
if user.tenant_id.as_ref() == Some(&theme.tenant_id)
&& theme.org_id.as_ref() == Some(&user.org_id)
&& theme.merchant_id.as_ref() == Some(&user.merchant_id)
&& theme.profile_id.is_none()
{
Ok(())
} else {
Err(UserErrors::ThemeNotFound.into())
}
}
EntityType::Profile => {
if user.tenant_id.as_ref() == Some(&theme.tenant_id)
&& theme.org_id.as_ref() == Some(&user.org_id)
&& theme.merchant_id.as_ref() == Some(&user.merchant_id)
&& theme.profile_id.as_ref() == Some(&user.profile_id)
{
Ok(())
} else {
Err(UserErrors::ThemeNotFound.into())
}
}
}
}

View File

@ -523,6 +523,20 @@ pub enum Flow {
UpdateTheme,
/// Delete theme
DeleteTheme,
/// Create user theme
CreateUserTheme,
/// Update user theme
UpdateUserTheme,
/// Delete user theme
DeleteUserTheme,
/// Upload file to user theme storage
UploadFileToUserThemeStorage,
/// Get user theme using theme id
GetUserThemeUsingThemeId,
///List All Themes In Lineage
ListAllThemesInLineage,
/// Get user theme using lineage
GetUserThemeUsingLineage,
/// List initial webhook delivery attempts
WebhookEventInitialDeliveryAttemptList,
/// List delivery attempts for a webhook event