mirror of
https://github.com/juspay/hyperswitch.git
synced 2026-03-13 09:02:06 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user