feat(users): Incorporate themes in user APIs (#6772)

Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com>
This commit is contained in:
Mani Chandra
2024-12-16 14:23:31 +05:30
committed by GitHub
parent da46427614
commit 4b989fe0fb
27 changed files with 687 additions and 89 deletions

View File

@@ -797,5 +797,12 @@ host = "localhost" # Client Host
port = 7000 # Client Port
service = "dynamo" # Service name
[theme_storage]
[theme.storage]
file_storage_backend = "file_system" # Theme storage backend to be used
[theme.email_config]
entity_name = "Hyperswitch" # Name of the entity to be showed in emails
entity_logo_url = "https://example.com/logo.svg" # Logo URL of the entity to be used in emails
foreground_color = "#000000" # Foreground color of email text
primary_color = "#006DF9" # Primary color of email body
background_color = "#FFFFFF" # Background color of email body

View File

@@ -328,9 +328,16 @@ host = "localhost" # Client Host
port = 7000 # Client Port
service = "dynamo" # Service name
[theme_storage]
[theme.storage]
file_storage_backend = "aws_s3" # Theme storage backend to be used
[theme_storage.aws_s3]
[theme.storage.aws_s3]
region = "bucket_region" # AWS region where the S3 bucket for theme storage is located
bucket_name = "bucket" # AWS S3 bucket name for theme storage
[theme.email_config]
entity_name = "Hyperswitch" # Name of the entity to be showed in emails
entity_logo_url = "https://example.com/logo.svg" # Logo URL of the entity to be used in emails
foreground_color = "#000000" # Foreground color of email text
primary_color = "#006DF9" # Primary color of email body
background_color = "#FFFFFF" # Background color of email body

View File

@@ -797,5 +797,12 @@ host = "localhost"
port = 7000
service = "dynamo"
[theme_storage]
file_storage_backend = "file_system"
[theme.storage]
file_storage_backend = "file_system" # Theme storage backend to be used
[theme.email_config]
entity_name = "Hyperswitch" # Name of the entity to be showed in emails
entity_logo_url = "https://example.com/logo.svg" # Logo URL of the entity to be used in emails
foreground_color = "#000000" # Foreground color of email text
primary_color = "#006DF9" # Primary color of email body
background_color = "#FFFFFF" # Background color of email body

View File

@@ -694,5 +694,12 @@ prod_intent_recipient_email = "business@example.com" # Recipient email for prod
email_role_arn = "" # The amazon resource name ( arn ) of the role which has permission to send emails
sts_role_session_name = "" # An identifier for the assumed role session, used to uniquely identify a session.
[theme_storage]
[theme.storage]
file_storage_backend = "file_system" # Theme storage backend to be used
[theme.email_config]
entity_name = "Hyperswitch" # Name of the entity to be showed in emails
entity_logo_url = "https://example.com/logo.svg" # Logo URL of the entity to be used in emails
foreground_color = "#000000" # Foreground color of email text
primary_color = "#006DF9" # Primary color of email body
background_color = "#FFFFFF" # Background color of email body

View File

@@ -150,6 +150,7 @@ pub struct GetUserDetailsResponse {
pub recovery_codes_left: Option<usize>,
pub profile_id: id_type::ProfileId,
pub entity_type: EntityType,
pub theme_id: Option<String>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
@@ -345,8 +346,9 @@ pub struct SsoSignInRequest {
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]
pub struct AuthIdQueryParam {
pub struct AuthIdAndThemeIdQueryParam {
pub auth_id: Option<String>,
pub theme_id: Option<String>,
}
#[derive(Debug, serde::Deserialize, serde::Serialize)]

View File

@@ -1,6 +1,9 @@
use actix_multipart::form::{bytes::Bytes, text::Text, MultipartForm};
use common_enums::EntityType;
use common_utils::{id_type, types::theme::ThemeLineage};
use common_utils::{
id_type,
types::theme::{EmailThemeConfig, ThemeLineage},
};
use masking::Secret;
use serde::{Deserialize, Serialize};
@@ -13,6 +16,7 @@ pub struct GetThemeResponse {
pub org_id: Option<id_type::OrganizationId>,
pub merchant_id: Option<id_type::MerchantId>,
pub profile_id: Option<id_type::ProfileId>,
pub email_config: EmailThemeConfig,
pub theme_data: ThemeData,
}
@@ -35,12 +39,14 @@ pub struct CreateThemeRequest {
pub lineage: ThemeLineage,
pub theme_name: String,
pub theme_data: ThemeData,
pub email_config: Option<EmailThemeConfig>,
}
#[derive(Serialize, Deserialize, Debug)]
pub struct UpdateThemeRequest {
pub lineage: ThemeLineage,
pub theme_data: ThemeData,
// TODO: Add support to update email config
}
// All the below structs are for the theme.json file,

View File

@@ -1,4 +1,5 @@
use common_enums::EntityType;
use serde::{Deserialize, Serialize};
use crate::{
events::{ApiEventMetric, ApiEventsType},
@@ -7,15 +8,14 @@ use crate::{
/// Enum for having all the required lineage for every level.
/// Currently being used for theme related APIs and queries.
#[derive(Debug, serde::Deserialize, serde::Serialize)]
#[derive(Debug, Deserialize, Serialize)]
#[serde(tag = "entity_type", rename_all = "snake_case")]
pub enum ThemeLineage {
// TODO: Add back Tenant variant when we introduce Tenant Variant in EntityType
// /// Tenant lineage variant
// Tenant {
// /// tenant_id: String
// tenant_id: String,
// },
/// Tenant lineage variant
Tenant {
/// tenant_id: TenantId
tenant_id: id_type::TenantId,
},
/// Org lineage variant
Organization {
/// tenant_id: TenantId
@@ -48,9 +48,35 @@ pub enum ThemeLineage {
impl_api_event_type!(Miscellaneous, (ThemeLineage));
impl ThemeLineage {
/// Constructor for ThemeLineage
pub fn new(
entity_type: EntityType,
tenant_id: id_type::TenantId,
org_id: id_type::OrganizationId,
merchant_id: id_type::MerchantId,
profile_id: id_type::ProfileId,
) -> Self {
match entity_type {
EntityType::Tenant => Self::Tenant { tenant_id },
EntityType::Organization => Self::Organization { tenant_id, org_id },
EntityType::Merchant => Self::Merchant {
tenant_id,
org_id,
merchant_id,
},
EntityType::Profile => Self::Profile {
tenant_id,
org_id,
merchant_id,
profile_id,
},
}
}
/// Get the entity_type from the lineage
pub fn entity_type(&self) -> EntityType {
match self {
Self::Tenant { .. } => EntityType::Tenant,
Self::Organization { .. } => EntityType::Organization,
Self::Merchant { .. } => EntityType::Merchant,
Self::Profile { .. } => EntityType::Profile,
@@ -60,7 +86,8 @@ impl ThemeLineage {
/// Get the tenant_id from the lineage
pub fn tenant_id(&self) -> &id_type::TenantId {
match self {
Self::Organization { tenant_id, .. }
Self::Tenant { tenant_id }
| Self::Organization { tenant_id, .. }
| Self::Merchant { tenant_id, .. }
| Self::Profile { tenant_id, .. } => tenant_id,
}
@@ -69,6 +96,7 @@ impl ThemeLineage {
/// Get the org_id from the lineage
pub fn org_id(&self) -> Option<&id_type::OrganizationId> {
match self {
Self::Tenant { .. } => None,
Self::Organization { org_id, .. }
| Self::Merchant { org_id, .. }
| Self::Profile { org_id, .. } => Some(org_id),
@@ -78,7 +106,7 @@ impl ThemeLineage {
/// Get the merchant_id from the lineage
pub fn merchant_id(&self) -> Option<&id_type::MerchantId> {
match self {
Self::Organization { .. } => None,
Self::Tenant { .. } | Self::Organization { .. } => None,
Self::Merchant { merchant_id, .. } | Self::Profile { merchant_id, .. } => {
Some(merchant_id)
}
@@ -88,8 +116,72 @@ impl ThemeLineage {
/// Get the profile_id from the lineage
pub fn profile_id(&self) -> Option<&id_type::ProfileId> {
match self {
Self::Organization { .. } | Self::Merchant { .. } => None,
Self::Tenant { .. } | Self::Organization { .. } | Self::Merchant { .. } => None,
Self::Profile { profile_id, .. } => Some(profile_id),
}
}
/// Get higher lineages from the current lineage
pub fn get_same_and_higher_lineages(self) -> Vec<Self> {
match &self {
Self::Tenant { .. } => vec![self],
Self::Organization { tenant_id, .. } => vec![
Self::Tenant {
tenant_id: tenant_id.clone(),
},
self,
],
Self::Merchant {
tenant_id, org_id, ..
} => vec![
Self::Tenant {
tenant_id: tenant_id.clone(),
},
Self::Organization {
tenant_id: tenant_id.clone(),
org_id: org_id.clone(),
},
self,
],
Self::Profile {
tenant_id,
org_id,
merchant_id,
..
} => vec![
Self::Tenant {
tenant_id: tenant_id.clone(),
},
Self::Organization {
tenant_id: tenant_id.clone(),
org_id: org_id.clone(),
},
Self::Merchant {
tenant_id: tenant_id.clone(),
org_id: org_id.clone(),
merchant_id: merchant_id.clone(),
},
self,
],
}
}
}
/// Struct for holding the theme settings for email
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct EmailThemeConfig {
/// The entity name to be used in the email
pub entity_name: String,
/// The URL of the entity logo to be used in the email
pub entity_logo_url: String,
/// The primary color to be used in the email
pub primary_color: String,
/// The foreground color to be used in the email
pub foreground_color: String,
/// The background color to be used in the email
pub background_color: String,
}

View File

@@ -1,13 +1,22 @@
use async_bb8_diesel::AsyncRunQueryDsl;
use common_utils::types::theme::ThemeLineage;
use diesel::{
associations::HasTable,
debug_query,
pg::Pg,
result::Error as DieselError,
sql_types::{Bool, Nullable},
BoolExpressionMethods, ExpressionMethods,
BoolExpressionMethods, ExpressionMethods, NullableExpressionMethods, QueryDsl,
};
use error_stack::{report, ResultExt};
use router_env::logger;
use crate::{
query::generics,
errors::DatabaseError,
query::generics::{
self,
db_metrics::{track_database_call, DatabaseOperation},
},
schema::themes::dsl,
user::theme::{Theme, ThemeNew},
PgPooledConn, StorageResult,
@@ -27,15 +36,14 @@ impl Theme {
+ 'static,
> {
match lineage {
// TODO: Add back Tenant variant when we introduce Tenant Variant in EntityType
// ThemeLineage::Tenant { tenant_id } => Box::new(
// dsl::tenant_id
// .eq(tenant_id)
// .and(dsl::org_id.is_null())
// .and(dsl::merchant_id.is_null())
// .and(dsl::profile_id.is_null())
// .nullable(),
// ),
ThemeLineage::Tenant { tenant_id } => Box::new(
dsl::tenant_id
.eq(tenant_id)
.and(dsl::org_id.is_null())
.and(dsl::merchant_id.is_null())
.and(dsl::profile_id.is_null())
.nullable(),
),
ThemeLineage::Organization { tenant_id, org_id } => Box::new(
dsl::tenant_id
.eq(tenant_id)
@@ -77,6 +85,41 @@ impl Theme {
.await
}
pub async fn find_most_specific_theme_in_lineage(
conn: &PgPooledConn,
lineage: ThemeLineage,
) -> StorageResult<Self> {
let query = <Self as HasTable>::table().into_boxed();
let query =
lineage
.get_same_and_higher_lineages()
.into_iter()
.fold(query, |mut query, lineage| {
query = query.or_filter(Self::lineage_filter(lineage));
query
});
logger::debug!(query = %debug_query::<Pg,_>(&query).to_string());
let data: Vec<Self> = match track_database_call::<Self, _, _>(
query.get_results_async(conn),
DatabaseOperation::Filter,
)
.await
{
Ok(value) => Ok(value),
Err(err) => match err {
DieselError::NotFound => Err(report!(err)).change_context(DatabaseError::NotFound),
_ => Err(report!(err)).change_context(DatabaseError::Others),
},
}?;
data.into_iter()
.min_by_key(|theme| theme.entity_type)
.ok_or(report!(DatabaseError::NotFound))
}
pub async fn find_by_lineage(
conn: &PgPooledConn,
lineage: ThemeLineage,

View File

@@ -1321,6 +1321,15 @@ diesel::table! {
entity_type -> Varchar,
#[max_length = 64]
theme_name -> Varchar,
#[max_length = 64]
email_primary_color -> Varchar,
#[max_length = 64]
email_foreground_color -> Varchar,
#[max_length = 64]
email_background_color -> Varchar,
#[max_length = 64]
email_entity_name -> Varchar,
email_entity_logo_url -> Text,
}
}

View File

@@ -1268,6 +1268,15 @@ diesel::table! {
entity_type -> Varchar,
#[max_length = 64]
theme_name -> Varchar,
#[max_length = 64]
email_primary_color -> Varchar,
#[max_length = 64]
email_foreground_color -> Varchar,
#[max_length = 64]
email_background_color -> Varchar,
#[max_length = 64]
email_entity_name -> Varchar,
email_entity_logo_url -> Text,
}
}

View File

@@ -1,5 +1,8 @@
use common_enums::EntityType;
use common_utils::{date_time, id_type, types::theme::ThemeLineage};
use common_utils::{
date_time, id_type,
types::theme::{EmailThemeConfig, ThemeLineage},
};
use diesel::{Identifiable, Insertable, Queryable, Selectable};
use time::PrimitiveDateTime;
@@ -17,6 +20,11 @@ pub struct Theme {
pub last_modified_at: PrimitiveDateTime,
pub entity_type: EntityType,
pub theme_name: String,
pub email_primary_color: String,
pub email_foreground_color: String,
pub email_background_color: String,
pub email_entity_name: String,
pub email_entity_logo_url: String,
}
#[derive(Clone, Debug, Insertable, router_derive::DebugAsDisplay)]
@@ -31,10 +39,20 @@ pub struct ThemeNew {
pub last_modified_at: PrimitiveDateTime,
pub entity_type: EntityType,
pub theme_name: String,
pub email_primary_color: String,
pub email_foreground_color: String,
pub email_background_color: String,
pub email_entity_name: String,
pub email_entity_logo_url: String,
}
impl ThemeNew {
pub fn new(theme_id: String, theme_name: String, lineage: ThemeLineage) -> Self {
pub fn new(
theme_id: String,
theme_name: String,
lineage: ThemeLineage,
email_config: EmailThemeConfig,
) -> Self {
let now = date_time::now();
Self {
@@ -47,6 +65,23 @@ impl ThemeNew {
entity_type: lineage.entity_type(),
created_at: now,
last_modified_at: now,
email_primary_color: email_config.primary_color,
email_foreground_color: email_config.foreground_color,
email_background_color: email_config.background_color,
email_entity_name: email_config.entity_name,
email_entity_logo_url: email_config.entity_logo_url,
}
}
}
impl Theme {
pub fn email_config(&self) -> EmailThemeConfig {
EmailThemeConfig {
primary_color: self.email_primary_color.clone(),
foreground_color: self.email_foreground_color.clone(),
background_color: self.email_background_color.clone(),
entity_name: self.email_entity_name.clone(),
entity_logo_url: self.email_entity_logo_url.clone(),
}
}
}

View File

@@ -545,6 +545,6 @@ pub(crate) async fn fetch_raw_secrets(
.network_tokenization_supported_card_networks,
network_tokenization_service,
network_tokenization_supported_connectors: conf.network_tokenization_supported_connectors,
theme_storage: conf.theme_storage,
theme: conf.theme,
}
}

View File

@@ -6,7 +6,7 @@ use std::{
#[cfg(feature = "olap")]
use analytics::{opensearch::OpenSearchConfig, ReportConfig};
use api_models::{enums, payment_methods::RequiredFieldInfo};
use common_utils::{ext_traits::ConfigExt, id_type};
use common_utils::{ext_traits::ConfigExt, id_type, types::theme::EmailThemeConfig};
use config::{Environment, File};
use error_stack::ResultExt;
#[cfg(feature = "email")]
@@ -128,7 +128,7 @@ pub struct Settings<S: SecretState> {
pub network_tokenization_supported_card_networks: NetworkTokenizationSupportedCardNetworks,
pub network_tokenization_service: Option<SecretStateContainer<NetworkTokenizationService, S>>,
pub network_tokenization_supported_connectors: NetworkTokenizationSupportedConnectors,
pub theme_storage: FileStorageConfig,
pub theme: ThemeSettings,
}
#[derive(Debug, Deserialize, Clone, Default)]
@@ -887,7 +887,8 @@ impl Settings<SecuredSecret> {
.validate()
.map_err(|err| ApplicationError::InvalidConfigurationValueError(err.into()))?;
self.theme_storage
self.theme
.storage
.validate()
.map_err(|err| ApplicationError::InvalidConfigurationValueError(err.to_string()))?;
@@ -992,6 +993,12 @@ impl Default for CellInformation {
}
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct ThemeSettings {
pub storage: FileStorageConfig,
pub email_config: EmailThemeConfig,
}
fn deserialize_hashmap_inner<K, V>(
value: HashMap<String, String>,
) -> Result<HashMap<K, HashSet<V>>, String>

View File

@@ -106,6 +106,8 @@ pub enum UserErrors {
ThemeAlreadyExists,
#[error("Invalid field: {0} in lineage")]
InvalidThemeLineage(String),
#[error("Missing required field: email_config")]
MissingEmailConfig,
}
impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorResponse> for UserErrors {
@@ -275,6 +277,9 @@ impl common_utils::errors::ErrorSwitch<api_models::errors::types::ApiErrorRespon
Self::InvalidThemeLineage(_) => {
AER::BadRequest(ApiError::new(sub_code, 55, self.get_error_message(), None))
}
Self::MissingEmailConfig => {
AER::BadRequest(ApiError::new(sub_code, 56, self.get_error_message(), None))
}
}
}
}
@@ -341,6 +346,7 @@ impl UserErrors {
Self::InvalidThemeLineage(field_name) => {
format!("Invalid field: {} in lineage", field_name)
}
Self::MissingEmailConfig => "Missing required field: email_config".to_string(),
}
}
}

View File

@@ -1,12 +1,14 @@
use api_models::recon as recon_api;
#[cfg(feature = "email")]
use common_utils::ext_traits::AsyncExt;
use common_utils::{ext_traits::AsyncExt, types::theme::ThemeLineage};
use error_stack::ResultExt;
#[cfg(feature = "email")]
use masking::{ExposeInterface, PeekInterface, Secret};
#[cfg(feature = "email")]
use crate::{consts, services::email::types as email_types, types::domain};
use crate::{
consts, services::email::types as email_types, types::domain, utils::user::theme as theme_utils,
};
use crate::{
core::errors::{self, RouterResponse, UserErrors, UserResponse},
services::{api as service_api, authentication},
@@ -35,6 +37,21 @@ pub async fn send_recon_request(
let user_in_db = &auth_data.user;
let merchant_id = auth_data.merchant_account.get_id().clone();
let theme = theme_utils::get_most_specific_theme_using_lineage(
&state.clone(),
ThemeLineage::Merchant {
tenant_id: state.tenant.tenant_id.clone(),
org_id: auth_data.merchant_account.get_org_id().clone(),
merchant_id: merchant_id.clone(),
},
)
.await
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable(format!(
"Failed to fetch theme for merchant_id = {:?}",
merchant_id
))?;
let user_email = user_in_db.email.clone();
let email_contents = email_types::ProFeatureRequest {
feature_name: consts::RECON_FEATURE_TAG.to_string(),
@@ -55,6 +72,10 @@ pub async fn send_recon_request(
consts::EMAIL_SUBJECT_DASHBOARD_FEATURE_REQUEST,
user_email.expose().peek()
),
theme_id: theme.as_ref().map(|theme| theme.theme_id.clone()),
theme_config: theme
.map(|theme| theme.email_config())
.unwrap_or(state.conf.theme.email_config.clone()),
};
state
.email_client
@@ -141,7 +162,7 @@ pub async fn recon_merchant_account_update(
let updated_merchant_account = db
.update_merchant(
key_manager_state,
auth.merchant_account,
auth.merchant_account.clone(),
updated_merchant_account,
&auth.key_store,
)
@@ -154,6 +175,22 @@ pub async fn recon_merchant_account_update(
#[cfg(feature = "email")]
{
let user_email = &req.user_email.clone();
let theme = theme_utils::get_most_specific_theme_using_lineage(
&state.clone(),
ThemeLineage::Merchant {
tenant_id: state.tenant.tenant_id,
org_id: auth.merchant_account.get_org_id().clone(),
merchant_id: merchant_id.clone(),
},
)
.await
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable(format!(
"Failed to fetch theme for merchant_id = {:?}",
merchant_id
))?;
let email_contents = email_types::ReconActivation {
recipient_email: domain::UserEmail::from_pii_email(user_email.clone())
.change_context(errors::ApiErrorResponse::InternalServerError)
@@ -164,6 +201,10 @@ pub async fn recon_merchant_account_update(
.change_context(errors::ApiErrorResponse::InternalServerError)
.attach_printable("Failed to form username")?,
subject: consts::EMAIL_SUBJECT_APPROVAL_RECON_REQUEST,
theme_id: theme.as_ref().map(|theme| theme.theme_id.clone()),
theme_config: theme
.map(|theme| theme.email_config())
.unwrap_or(state.conf.theme.email_config.clone()),
};
if req.recon_status == enums::ReconStatus::Active {
let _ = state

View File

@@ -42,7 +42,10 @@ use crate::{
routes::{app::ReqState, SessionState},
services::{authentication as auth, authorization::roles, openidconnect, ApplicationResponse},
types::{domain, transformers::ForeignInto},
utils::{self, user::two_factor_auth as tfa_utils},
utils::{
self,
user::{theme as theme_utils, two_factor_auth as tfa_utils},
},
};
pub mod dashboard_metadata;
@@ -55,6 +58,7 @@ pub async fn signup_with_merchant_id(
state: SessionState,
request: user_api::SignUpWithMerchantIdRequest,
auth_id: Option<String>,
theme_id: Option<String>,
) -> UserResponse<user_api::SignUpWithMerchantIdResponse> {
let new_user = domain::NewUser::try_from(request.clone())?;
new_user
@@ -75,12 +79,18 @@ pub async fn signup_with_merchant_id(
)
.await?;
let theme = theme_utils::get_theme_using_optional_theme_id(&state, theme_id).await?;
let email_contents = email_types::ResetPassword {
recipient_email: user_from_db.get_email().try_into()?,
user_name: domain::UserName::new(user_from_db.get_name())?,
settings: state.conf.clone(),
subject: consts::user::EMAIL_SUBJECT_RESET_PASSWORD,
auth_id,
theme_id: theme.as_ref().map(|theme| theme.theme_id.clone()),
theme_config: theme
.map(|theme| theme.email_config())
.unwrap_or(state.conf.theme.email_config.clone()),
};
let send_email_result = state
@@ -112,6 +122,13 @@ pub async fn get_user_details(
.await
.change_context(UserErrors::InternalServerError)?;
let theme = theme_utils::get_most_specific_theme_using_token_and_min_entity(
&state,
&user_from_token,
EntityType::Profile,
)
.await?;
Ok(ApplicationResponse::Json(
user_api::GetUserDetailsResponse {
merchant_id: user_from_token.merchant_id,
@@ -125,6 +142,7 @@ pub async fn get_user_details(
recovery_codes_left: user.get_recovery_codes().map(|codes| codes.len()),
profile_id: user_from_token.profile_id,
entity_type: role_info.get_entity_type(),
theme_id: theme.map(|theme| theme.theme_id),
},
))
}
@@ -194,6 +212,7 @@ pub async fn connect_account(
state: SessionState,
request: user_api::ConnectAccountRequest,
auth_id: Option<String>,
theme_id: Option<String>,
) -> UserResponse<user_api::ConnectAccountResponse> {
let find_user = state
.global_store
@@ -203,12 +222,18 @@ pub async fn connect_account(
if let Ok(found_user) = find_user {
let user_from_db: domain::UserFromStorage = found_user.into();
let theme = theme_utils::get_theme_using_optional_theme_id(&state, theme_id).await?;
let email_contents = email_types::MagicLink {
recipient_email: domain::UserEmail::from_pii_email(user_from_db.get_email())?,
settings: state.conf.clone(),
user_name: domain::UserName::new(user_from_db.get_name())?,
subject: consts::user::EMAIL_SUBJECT_MAGIC_LINK,
auth_id,
theme_id: theme.as_ref().map(|theme| theme.theme_id.clone()),
theme_config: theme
.map(|theme| theme.email_config())
.unwrap_or(state.conf.theme.email_config.clone()),
};
let send_email_result = state
@@ -253,11 +278,17 @@ pub async fn connect_account(
)
.await?;
let theme = theme_utils::get_theme_using_optional_theme_id(&state, theme_id).await?;
let magic_link_email = email_types::VerifyEmail {
recipient_email: domain::UserEmail::from_pii_email(user_from_db.get_email())?,
settings: state.conf.clone(),
subject: consts::user::EMAIL_SUBJECT_SIGNUP,
auth_id,
theme_id: theme.as_ref().map(|theme| theme.theme_id.clone()),
theme_config: theme
.map(|theme| theme.email_config())
.unwrap_or(state.conf.theme.email_config.clone()),
};
let magic_link_result = state
@@ -270,20 +301,22 @@ pub async fn connect_account(
logger::info!(?magic_link_result);
let welcome_to_community_email = email_types::WelcomeToCommunity {
recipient_email: domain::UserEmail::from_pii_email(user_from_db.get_email())?,
subject: consts::user::EMAIL_SUBJECT_WELCOME_TO_COMMUNITY,
};
if state.tenant.tenant_id.get_string_repr() == common_utils::consts::DEFAULT_TENANT {
let welcome_to_community_email = email_types::WelcomeToCommunity {
recipient_email: domain::UserEmail::from_pii_email(user_from_db.get_email())?,
subject: consts::user::EMAIL_SUBJECT_WELCOME_TO_COMMUNITY,
};
let welcome_email_result = state
.email_client
.compose_and_send_email(
Box::new(welcome_to_community_email),
state.conf.proxy.https_url.as_ref(),
)
.await;
let welcome_email_result = state
.email_client
.compose_and_send_email(
Box::new(welcome_to_community_email),
state.conf.proxy.https_url.as_ref(),
)
.await;
logger::info!(?welcome_email_result);
logger::info!(?welcome_email_result);
}
return Ok(ApplicationResponse::Json(
user_api::ConnectAccountResponse {
@@ -371,6 +404,7 @@ pub async fn forgot_password(
state: SessionState,
request: user_api::ForgotPasswordRequest,
auth_id: Option<String>,
theme_id: Option<String>,
) -> UserResponse<()> {
let user_email = domain::UserEmail::from_pii_email(request.email)?;
@@ -387,12 +421,18 @@ pub async fn forgot_password(
})
.map(domain::UserFromStorage::from)?;
let theme = theme_utils::get_theme_using_optional_theme_id(&state, theme_id).await?;
let email_contents = email_types::ResetPassword {
recipient_email: domain::UserEmail::from_pii_email(user_from_db.get_email())?,
settings: state.conf.clone(),
user_name: domain::UserName::new(user_from_db.get_name())?,
subject: consts::user::EMAIL_SUBJECT_RESET_PASSWORD,
auth_id,
theme_id: theme.as_ref().map(|theme| theme.theme_id.clone()),
theme_config: theme
.map(|theme| theme.email_config())
.unwrap_or(state.conf.theme.email_config.clone()),
};
state
@@ -782,6 +822,13 @@ async fn handle_existing_user_invitation(
},
};
let theme = theme_utils::get_most_specific_theme_using_token_and_min_entity(
state,
user_from_token,
role_info.get_entity_type(),
)
.await?;
let email_contents = email_types::InviteUser {
recipient_email: invitee_email,
user_name: domain::UserName::new(invitee_user_from_db.get_name())?,
@@ -789,6 +836,10 @@ async fn handle_existing_user_invitation(
subject: consts::user::EMAIL_SUBJECT_INVITATION,
entity,
auth_id: auth_id.clone(),
theme_id: theme.as_ref().map(|theme| theme.theme_id.clone()),
theme_config: theme
.map(|theme| theme.email_config())
.unwrap_or(state.conf.theme.email_config.clone()),
};
is_email_sent = state
@@ -927,6 +978,13 @@ async fn handle_new_user_invitation(
},
};
let theme = theme_utils::get_most_specific_theme_using_token_and_min_entity(
state,
user_from_token,
role_info.get_entity_type(),
)
.await?;
let email_contents = email_types::InviteUser {
recipient_email: invitee_email,
user_name: domain::UserName::new(new_user.get_name())?,
@@ -934,6 +992,10 @@ async fn handle_new_user_invitation(
subject: consts::user::EMAIL_SUBJECT_INVITATION,
entity,
auth_id: auth_id.clone(),
theme_id: theme.as_ref().map(|theme| theme.theme_id.clone()),
theme_config: theme
.map(|theme| theme.email_config())
.unwrap_or(state.conf.theme.email_config.clone()),
};
let send_email_result = state
.email_client
@@ -1055,6 +1117,21 @@ pub async fn resend_invite(
.get_entity_id_and_type()
.ok_or(UserErrors::InternalServerError)?;
let invitee_role_info = roles::RoleInfo::from_role_id_and_org_id(
&state,
&user_role.role_id,
&user_from_token.org_id,
)
.await
.change_context(UserErrors::InternalServerError)?;
let theme = theme_utils::get_most_specific_theme_using_token_and_min_entity(
&state,
&user_from_token,
invitee_role_info.get_entity_type(),
)
.await?;
let email_contents = email_types::InviteUser {
recipient_email: invitee_email,
user_name: domain::UserName::new(user.get_name())?,
@@ -1065,6 +1142,10 @@ pub async fn resend_invite(
entity_type,
},
auth_id: auth_id.clone(),
theme_id: theme.as_ref().map(|theme| theme.theme_id.clone()),
theme_config: theme
.map(|theme| theme.email_config())
.unwrap_or(state.conf.theme.email_config.clone()),
};
state
@@ -1666,6 +1747,7 @@ pub async fn send_verification_mail(
state: SessionState,
req: user_api::SendVerifyEmailRequest,
auth_id: Option<String>,
theme_id: Option<String>,
) -> UserResponse<()> {
let user_email = domain::UserEmail::try_from(req.email)?;
let user = state
@@ -1684,11 +1766,17 @@ pub async fn send_verification_mail(
return Err(UserErrors::UserAlreadyVerified.into());
}
let theme = theme_utils::get_theme_using_optional_theme_id(&state, theme_id).await?;
let email_contents = email_types::VerifyEmail {
recipient_email: domain::UserEmail::from_pii_email(user.email)?,
settings: state.conf.clone(),
subject: consts::user::EMAIL_SUBJECT_SIGNUP,
auth_id,
theme_id: theme.as_ref().map(|theme| theme.theme_id.clone()),
theme_config: theme
.map(|theme| theme.email_config())
.unwrap_or(state.conf.theme.email_config.clone()),
};
state

View File

@@ -1,4 +1,6 @@
use api_models::user::dashboard_metadata::{self as api, GetMultipleMetaDataPayload};
#[cfg(feature = "email")]
use common_enums::EntityType;
use diesel_models::{
enums::DashboardMetadata as DBEnum, user::dashboard_metadata::DashboardMetadata,
};
@@ -8,8 +10,6 @@ use masking::ExposeInterface;
#[cfg(feature = "email")]
use router_env::logger;
#[cfg(feature = "email")]
use crate::services::email::types as email_types;
use crate::{
core::errors::{UserErrors, UserResponse, UserResult},
routes::{app::ReqState, SessionState},
@@ -17,6 +17,8 @@ use crate::{
types::domain::{self, user::dashboard_metadata as types, MerchantKeyStore},
utils::user::dashboard_metadata as utils,
};
#[cfg(feature = "email")]
use crate::{services::email::types as email_types, utils::user::theme as theme_utils};
pub async fn set_metadata(
state: SessionState,
@@ -476,7 +478,21 @@ async fn insert_metadata(
.expose();
if utils::is_prod_email_required(&data, user_email) {
let email_contents = email_types::BizEmailProd::new(state, data)?;
let theme = theme_utils::get_most_specific_theme_using_token_and_min_entity(
state,
&user,
EntityType::Merchant,
)
.await?;
let email_contents = email_types::BizEmailProd::new(
state,
data,
theme.as_ref().map(|theme| theme.theme_id.clone()),
theme
.map(|theme| theme.email_config())
.unwrap_or(state.conf.theme.email_config.clone()),
)?;
let send_email_result = state
.email_client
.compose_and_send_email(

View File

@@ -38,6 +38,7 @@ pub async fn get_theme_using_lineage(
.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,
@@ -71,6 +72,7 @@ pub async fn get_theme_using_theme_id(
.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,
@@ -113,10 +115,19 @@ pub async fn create_theme(
) -> UserResponse<theme_api::GetThemeResponse> {
theme_utils::validate_lineage(&state, &request.lineage).await?;
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 new_theme = ThemeNew::new(
Uuid::new_v4().to_string(),
request.theme_name,
request.lineage,
email_config,
);
let db_theme = state
@@ -147,6 +158,7 @@ pub async fn create_theme(
.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,
@@ -195,6 +207,7 @@ pub async fn update_theme(
.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,

View File

@@ -3755,6 +3755,15 @@ impl ThemeInterface for KafkaStore {
self.diesel_store.find_theme_by_theme_id(theme_id).await
}
async fn find_most_specific_theme_in_lineage(
&self,
lineage: ThemeLineage,
) -> CustomResult<diesel_models::user::theme::Theme, errors::StorageError> {
self.diesel_store
.find_most_specific_theme_in_lineage(lineage)
.await
}
async fn find_theme_by_lineage(
&self,
lineage: ThemeLineage,

View File

@@ -21,6 +21,11 @@ pub trait ThemeInterface {
theme_id: String,
) -> CustomResult<storage::Theme, errors::StorageError>;
async fn find_most_specific_theme_in_lineage(
&self,
lineage: ThemeLineage,
) -> CustomResult<storage::Theme, errors::StorageError>;
async fn find_theme_by_lineage(
&self,
lineage: ThemeLineage,
@@ -56,6 +61,16 @@ impl ThemeInterface for Store {
.map_err(|error| report!(errors::StorageError::from(error)))
}
async fn find_most_specific_theme_in_lineage(
&self,
lineage: ThemeLineage,
) -> CustomResult<storage::Theme, errors::StorageError> {
let conn = connection::pg_connection_read(self).await?;
storage::Theme::find_most_specific_theme_in_lineage(&conn, lineage)
.await
.map_err(|error| report!(errors::StorageError::from(error)))
}
async fn find_theme_by_lineage(
&self,
lineage: ThemeLineage,
@@ -80,13 +95,12 @@ impl ThemeInterface for Store {
fn check_theme_with_lineage(theme: &storage::Theme, lineage: &ThemeLineage) -> bool {
match lineage {
// TODO: Add back Tenant variant when we introduce Tenant Variant in EntityType
// ThemeLineage::Tenant { tenant_id } => {
// &theme.tenant_id == tenant_id
// && theme.org_id.is_none()
// && theme.merchant_id.is_none()
// && theme.profile_id.is_none()
// }
ThemeLineage::Tenant { tenant_id } => {
&theme.tenant_id == tenant_id
&& theme.org_id.is_none()
&& theme.merchant_id.is_none()
&& theme.profile_id.is_none()
}
ThemeLineage::Organization { tenant_id, org_id } => {
&theme.tenant_id == tenant_id
&& theme
@@ -174,6 +188,11 @@ impl ThemeInterface for MockDb {
last_modified_at: new_theme.last_modified_at,
entity_type: new_theme.entity_type,
theme_name: new_theme.theme_name,
email_primary_color: new_theme.email_primary_color,
email_foreground_color: new_theme.email_foreground_color,
email_background_color: new_theme.email_background_color,
email_entity_name: new_theme.email_entity_name,
email_entity_logo_url: new_theme.email_entity_logo_url,
};
themes.push(theme.clone());
@@ -198,6 +217,27 @@ impl ThemeInterface for MockDb {
)
}
async fn find_most_specific_theme_in_lineage(
&self,
lineage: ThemeLineage,
) -> CustomResult<storage::Theme, errors::StorageError> {
let themes = self.themes.lock().await;
let lineages = lineage.get_same_and_higher_lineages();
themes
.iter()
.filter(|theme| {
lineages
.iter()
.any(|lineage| check_theme_with_lineage(theme, lineage))
})
.min_by_key(|theme| theme.entity_type)
.ok_or(
errors::StorageError::ValueNotFound("No theme found in lineage".to_string()).into(),
)
.cloned()
}
async fn find_theme_by_lineage(
&self,
lineage: ThemeLineage,

View File

@@ -369,7 +369,7 @@ impl AppState {
let email_client = Arc::new(create_email_client(&conf).await);
let file_storage_client = conf.file_storage.get_file_storage_client().await;
let theme_storage_client = conf.theme_storage.get_file_storage_client().await;
let theme_storage_client = conf.theme.storage.get_file_storage_client().await;
let grpc_client = conf.grpc_client.get_grpc_client_interface().await;

View File

@@ -41,18 +41,23 @@ pub async fn user_signup_with_merchant_id(
state: web::Data<AppState>,
http_req: HttpRequest,
json_payload: web::Json<user_api::SignUpWithMerchantIdRequest>,
query: web::Query<user_api::AuthIdQueryParam>,
query: web::Query<user_api::AuthIdAndThemeIdQueryParam>,
) -> HttpResponse {
let flow = Flow::UserSignUpWithMerchantId;
let req_payload = json_payload.into_inner();
let auth_id = query.into_inner().auth_id;
let query_params = query.into_inner();
Box::pin(api::server_wrap(
flow.clone(),
state,
&http_req,
req_payload.clone(),
|state, _, req_body, _| {
user_core::signup_with_merchant_id(state, req_body, auth_id.clone())
user_core::signup_with_merchant_id(
state,
req_body,
query_params.auth_id.clone(),
query_params.theme_id.clone(),
)
},
&auth::AdminApiAuth,
api_locking::LockAction::NotApplicable,
@@ -107,17 +112,24 @@ pub async fn user_connect_account(
state: web::Data<AppState>,
http_req: HttpRequest,
json_payload: web::Json<user_api::ConnectAccountRequest>,
query: web::Query<user_api::AuthIdQueryParam>,
query: web::Query<user_api::AuthIdAndThemeIdQueryParam>,
) -> HttpResponse {
let flow = Flow::UserConnectAccount;
let req_payload = json_payload.into_inner();
let auth_id = query.into_inner().auth_id;
let query_params = query.into_inner();
Box::pin(api::server_wrap(
flow.clone(),
state,
&http_req,
req_payload.clone(),
|state, _: (), req_body, _| user_core::connect_account(state, req_body, auth_id.clone()),
|state, _: (), req_body, _| {
user_core::connect_account(
state,
req_body,
query_params.auth_id.clone(),
query_params.theme_id.clone(),
)
},
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
))
@@ -381,16 +393,23 @@ pub async fn forgot_password(
state: web::Data<AppState>,
req: HttpRequest,
payload: web::Json<user_api::ForgotPasswordRequest>,
query: web::Query<user_api::AuthIdQueryParam>,
query: web::Query<user_api::AuthIdAndThemeIdQueryParam>,
) -> HttpResponse {
let flow = Flow::ForgotPassword;
let auth_id = query.into_inner().auth_id;
let query_params = query.into_inner();
Box::pin(api::server_wrap(
flow,
state.clone(),
&req,
payload.into_inner(),
|state, _: (), payload, _| user_core::forgot_password(state, payload, auth_id.clone()),
|state, _: (), payload, _| {
user_core::forgot_password(
state,
payload,
query_params.auth_id.clone(),
query_params.theme_id.clone(),
)
},
&auth::NoAuth,
api_locking::LockAction::NotApplicable,
))
@@ -420,7 +439,7 @@ pub async fn invite_multiple_user(
state: web::Data<AppState>,
req: HttpRequest,
payload: web::Json<Vec<user_api::InviteUserRequest>>,
auth_id_query_param: web::Query<user_api::AuthIdQueryParam>,
auth_id_query_param: web::Query<user_api::AuthIdAndThemeIdQueryParam>,
) -> HttpResponse {
let flow = Flow::InviteMultipleUser;
let auth_id = auth_id_query_param.into_inner().auth_id;
@@ -445,7 +464,7 @@ pub async fn resend_invite(
state: web::Data<AppState>,
req: HttpRequest,
payload: web::Json<user_api::ReInviteUserRequest>,
query: web::Query<user_api::AuthIdQueryParam>,
query: web::Query<user_api::AuthIdAndThemeIdQueryParam>,
) -> HttpResponse {
let flow = Flow::ReInviteUser;
let auth_id = query.into_inner().auth_id;
@@ -512,17 +531,22 @@ pub async fn verify_email_request(
state: web::Data<AppState>,
http_req: HttpRequest,
json_payload: web::Json<user_api::SendVerifyEmailRequest>,
query: web::Query<user_api::AuthIdQueryParam>,
query: web::Query<user_api::AuthIdAndThemeIdQueryParam>,
) -> HttpResponse {
let flow = Flow::VerifyEmailRequest;
let auth_id = query.into_inner().auth_id;
let query_params = query.into_inner();
Box::pin(api::server_wrap(
flow,
state.clone(),
&http_req,
json_payload.into_inner(),
|state, _: (), req_body, _| {
user_core::send_verification_mail(state, req_body, auth_id.clone())
user_core::send_verification_mail(
state,
req_body,
query_params.auth_id.clone(),
query_params.theme_id.clone(),
)
},
&auth::NoAuth,
api_locking::LockAction::NotApplicable,

View File

@@ -1,6 +1,6 @@
use api_models::user::dashboard_metadata::ProdIntent;
use common_enums::EntityType;
use common_utils::{errors::CustomResult, pii};
use common_utils::{errors::CustomResult, pii, types::theme::EmailThemeConfig};
use error_stack::ResultExt;
use external_services::email::{EmailContents, EmailData, EmailError};
use masking::{ExposeInterface, Secret};
@@ -212,13 +212,17 @@ pub fn get_link_with_token(
token: impl std::fmt::Display,
action: impl std::fmt::Display,
auth_id: &Option<impl std::fmt::Display>,
theme_id: &Option<impl std::fmt::Display>,
) -> String {
let email_url = format!("{base_url}/user/{action}?token={token}");
let mut email_url = format!("{base_url}/user/{action}?token={token}");
if let Some(auth_id) = auth_id {
format!("{email_url}&auth_id={auth_id}")
} else {
email_url
email_url = format!("{email_url}&auth_id={auth_id}");
}
if let Some(theme_id) = theme_id {
email_url = format!("{email_url}&theme_id={theme_id}");
}
email_url
}
pub struct VerifyEmail {
@@ -226,6 +230,8 @@ pub struct VerifyEmail {
pub settings: std::sync::Arc<configs::Settings>,
pub subject: &'static str,
pub auth_id: Option<String>,
pub theme_id: Option<String>,
pub theme_config: EmailThemeConfig,
}
/// Currently only HTML is supported
@@ -246,6 +252,7 @@ impl EmailData for VerifyEmail {
token,
"verify_email",
&self.auth_id,
&self.theme_id,
);
let body = html::get_html_body(EmailBody::Verify {
@@ -266,6 +273,8 @@ pub struct ResetPassword {
pub settings: std::sync::Arc<configs::Settings>,
pub subject: &'static str,
pub auth_id: Option<String>,
pub theme_id: Option<String>,
pub theme_config: EmailThemeConfig,
}
#[async_trait::async_trait]
@@ -285,6 +294,7 @@ impl EmailData for ResetPassword {
token,
"set_password",
&self.auth_id,
&self.theme_id,
);
let body = html::get_html_body(EmailBody::Reset {
@@ -306,6 +316,8 @@ pub struct MagicLink {
pub settings: std::sync::Arc<configs::Settings>,
pub subject: &'static str,
pub auth_id: Option<String>,
pub theme_id: Option<String>,
pub theme_config: EmailThemeConfig,
}
#[async_trait::async_trait]
@@ -325,6 +337,7 @@ impl EmailData for MagicLink {
token,
"verify_email",
&self.auth_id,
&self.theme_id,
);
let body = html::get_html_body(EmailBody::MagicLink {
@@ -347,6 +360,8 @@ pub struct InviteUser {
pub subject: &'static str,
pub entity: Entity,
pub auth_id: Option<String>,
pub theme_id: Option<String>,
pub theme_config: EmailThemeConfig,
}
#[async_trait::async_trait]
@@ -366,6 +381,7 @@ impl EmailData for InviteUser {
token,
"accept_invite_from_email",
&self.auth_id,
&self.theme_id,
);
let body = html::get_html_body(EmailBody::AcceptInviteFromEmail {
link: invite_user_link,
@@ -384,6 +400,8 @@ pub struct ReconActivation {
pub recipient_email: domain::UserEmail,
pub user_name: domain::UserName,
pub subject: &'static str,
pub theme_id: Option<String>,
pub theme_config: EmailThemeConfig,
}
#[async_trait::async_trait]
@@ -410,10 +428,17 @@ pub struct BizEmailProd {
pub business_website: String,
pub settings: std::sync::Arc<configs::Settings>,
pub subject: &'static str,
pub theme_id: Option<String>,
pub theme_config: EmailThemeConfig,
}
impl BizEmailProd {
pub fn new(state: &SessionState, data: ProdIntent) -> UserResult<Self> {
pub fn new(
state: &SessionState,
data: ProdIntent,
theme_id: Option<String>,
theme_config: EmailThemeConfig,
) -> UserResult<Self> {
Ok(Self {
recipient_email: domain::UserEmail::from_pii_email(
state.conf.email.prod_intent_recipient_email.clone(),
@@ -428,6 +453,8 @@ impl BizEmailProd {
.unwrap_or(common_enums::CountryAlpha2::AD)
.to_string(),
business_website: data.business_website.unwrap_or_default(),
theme_id,
theme_config,
})
}
}
@@ -458,6 +485,8 @@ pub struct ProFeatureRequest {
pub user_name: domain::UserName,
pub user_email: domain::UserEmail,
pub subject: String,
pub theme_id: Option<String>,
pub theme_config: EmailThemeConfig,
}
#[async_trait::async_trait]
@@ -486,6 +515,8 @@ pub struct ApiKeyExpiryReminder {
pub expires_in: u8,
pub api_key_name: String,
pub prefix: String,
pub theme_id: Option<String>,
pub theme_config: EmailThemeConfig,
}
#[async_trait::async_trait]

View File

@@ -1,12 +1,15 @@
use std::path::PathBuf;
use common_utils::{id_type, types::theme::ThemeLineage};
use common_enums::EntityType;
use common_utils::{ext_traits::AsyncExt, id_type, types::theme::ThemeLineage};
use diesel_models::user::theme::Theme;
use error_stack::ResultExt;
use hyperswitch_domain_models::merchant_key_store::MerchantKeyStore;
use crate::{
core::errors::{StorageErrorExt, UserErrors, UserResult},
routes::SessionState,
services::authentication::UserFromToken,
};
fn get_theme_dir_key(theme_id: &str) -> PathBuf {
@@ -54,6 +57,10 @@ pub async fn upload_file_to_theme_bucket(
pub async fn validate_lineage(state: &SessionState, lineage: &ThemeLineage) -> UserResult<()> {
match lineage {
ThemeLineage::Tenant { tenant_id } => {
validate_tenant(state, tenant_id)?;
Ok(())
}
ThemeLineage::Organization { tenant_id, org_id } => {
validate_tenant(state, tenant_id)?;
validate_org(state, org_id).await?;
@@ -96,8 +103,8 @@ async fn validate_org(state: &SessionState, org_id: &id_type::OrganizationId) ->
.store
.find_organization_by_org_id(org_id)
.await
.to_not_found_response(UserErrors::InvalidThemeLineage("org_id".to_string()))?;
Ok(())
.to_not_found_response(UserErrors::InvalidThemeLineage("org_id".to_string()))
.map(|_| ())
}
async fn validate_merchant_and_get_key_store(
@@ -153,6 +160,67 @@ async fn validate_profile(
profile_id,
)
.await
.to_not_found_response(UserErrors::InvalidThemeLineage("profile_id".to_string()))?;
Ok(())
.to_not_found_response(UserErrors::InvalidThemeLineage("profile_id".to_string()))
.map(|_| ())
}
pub async fn get_most_specific_theme_using_token_and_min_entity(
state: &SessionState,
user_from_token: &UserFromToken,
min_entity: EntityType,
) -> UserResult<Option<Theme>> {
get_most_specific_theme_using_lineage(
state,
ThemeLineage::new(
min_entity,
user_from_token
.tenant_id
.clone()
.unwrap_or(state.tenant.tenant_id.clone()),
user_from_token.org_id.clone(),
user_from_token.merchant_id.clone(),
user_from_token.profile_id.clone(),
),
)
.await
}
pub async fn get_most_specific_theme_using_lineage(
state: &SessionState,
lineage: ThemeLineage,
) -> UserResult<Option<Theme>> {
match state
.global_store
.find_most_specific_theme_in_lineage(lineage)
.await
{
Ok(theme) => Ok(Some(theme)),
Err(e) => {
if e.current_context().is_db_not_found() {
Ok(None)
} else {
Err(e.change_context(UserErrors::InternalServerError))
}
}
}
}
pub async fn get_theme_using_optional_theme_id(
state: &SessionState,
theme_id: Option<String>,
) -> UserResult<Option<Theme>> {
match theme_id
.async_map(|theme_id| state.global_store.find_theme_by_theme_id(theme_id))
.await
.transpose()
{
Ok(theme) => Ok(theme),
Err(e) => {
if e.current_context().is_db_not_found() {
Ok(None)
} else {
Err(e.change_context(UserErrors::InternalServerError))
}
}
}
}

View File

@@ -1,4 +1,4 @@
use common_utils::{errors::ValidationError, ext_traits::ValueExt};
use common_utils::{errors::ValidationError, ext_traits::ValueExt, types::theme::ThemeLineage};
use diesel_models::{
enums as storage_enums, process_tracker::business_status, ApiKeyExpiryTrackingData,
};
@@ -11,7 +11,7 @@ use crate::{
routes::{metrics, SessionState},
services::email::types::ApiKeyExpiryReminder,
types::{api, domain::UserEmail, storage},
utils::OptionExt,
utils::{user::theme as theme_utils, OptionExt},
};
pub struct ApiKeyExpiryWorkflow;
@@ -48,6 +48,7 @@ impl ProcessTrackerWorkflow<SessionState> for ApiKeyExpiryWorkflow {
let email_id = merchant_account
.merchant_details
.clone()
.parse_value::<api::MerchantDetails>("MerchantDetails")?
.primary_email
.ok_or(errors::ProcessTrackerError::EValidationError(
@@ -73,6 +74,20 @@ impl ProcessTrackerWorkflow<SessionState> for ApiKeyExpiryWorkflow {
)
.ok_or(errors::ProcessTrackerError::EApiErrorResponse)?;
let theme = theme_utils::get_most_specific_theme_using_lineage(
state,
ThemeLineage::Merchant {
tenant_id: state.tenant.tenant_id.clone(),
org_id: merchant_account.get_org_id().clone(),
merchant_id: merchant_account.get_id().clone(),
},
)
.await
.map_err(|err| {
logger::error!(?err, "Failed to get theme");
errors::ProcessTrackerError::EApiErrorResponse
})?;
let email_contents = ApiKeyExpiryReminder {
recipient_email: UserEmail::from_pii_email(email_id).map_err(|error| {
logger::error!(
@@ -85,6 +100,10 @@ impl ProcessTrackerWorkflow<SessionState> for ApiKeyExpiryWorkflow {
expires_in: *expires_in,
api_key_name,
prefix,
theme_id: theme.as_ref().map(|theme| theme.theme_id.clone()),
theme_config: theme
.map(|theme| theme.email_config())
.unwrap_or(state.conf.theme.email_config.clone()),
};
state

View File

@@ -0,0 +1,6 @@
-- This file should undo anything in `up.sql`
ALTER TABLE themes DROP COLUMN IF EXISTS email_primary_color;
ALTER TABLE themes DROP COLUMN IF EXISTS email_foreground_color;
ALTER TABLE themes DROP COLUMN IF EXISTS email_background_color;
ALTER TABLE themes DROP COLUMN IF EXISTS email_entity_name;
ALTER TABLE themes DROP COLUMN IF EXISTS email_entity_logo_url;

View File

@@ -0,0 +1,6 @@
-- Your SQL goes here
ALTER TABLE themes ADD COLUMN IF NOT EXISTS email_primary_color VARCHAR(64) NOT NULL DEFAULT '#006DF9';
ALTER TABLE themes ADD COLUMN IF NOT EXISTS email_foreground_color VARCHAR(64) NOT NULL DEFAULT '#000000';
ALTER TABLE themes ADD COLUMN IF NOT EXISTS email_background_color VARCHAR(64) NOT NULL DEFAULT '#FFFFFF';
ALTER TABLE themes ADD COLUMN IF NOT EXISTS email_entity_name VARCHAR(64) NOT NULL DEFAULT 'Hyperswitch';
ALTER TABLE themes ADD COLUMN IF NOT EXISTS email_entity_logo_url TEXT NOT NULL DEFAULT 'https://app.hyperswitch.io/email-assets/HyperswitchLogo.png';