From 4b989fe0fb7931479e127fecbaace42d989c0620 Mon Sep 17 00:00:00 2001 From: Mani Chandra <84711804+ThisIsMani@users.noreply.github.com> Date: Mon, 16 Dec 2024 14:23:31 +0530 Subject: [PATCH] feat(users): Incorporate themes in user APIs (#6772) Co-authored-by: hyperswitch-bot[bot] <148525504+hyperswitch-bot[bot]@users.noreply.github.com> --- config/config.example.toml | 9 +- config/deployments/env_specific.toml | 11 +- config/development.toml | 11 +- config/docker_compose.toml | 9 +- crates/api_models/src/user.rs | 4 +- crates/api_models/src/user/theme.rs | 8 +- crates/common_utils/src/types/theme.rs | 112 +++++++++++++++-- crates/diesel_models/src/query/user/theme.rs | 65 ++++++++-- crates/diesel_models/src/schema.rs | 9 ++ crates/diesel_models/src/schema_v2.rs | 9 ++ crates/diesel_models/src/user/theme.rs | 39 +++++- .../src/configs/secrets_transformers.rs | 2 +- crates/router/src/configs/settings.rs | 13 +- crates/router/src/core/errors/user.rs | 6 + crates/router/src/core/recon.rs | 47 +++++++- crates/router/src/core/user.rs | 114 ++++++++++++++++-- .../src/core/user/dashboard_metadata.rs | 22 +++- crates/router/src/core/user/theme.rs | 13 ++ crates/router/src/db/kafka_store.rs | 9 ++ crates/router/src/db/user/theme.rs | 54 +++++++-- crates/router/src/routes/app.rs | 2 +- crates/router/src/routes/user.rs | 52 +++++--- crates/router/src/services/email/types.rs | 43 ++++++- crates/router/src/utils/user/theme.rs | 78 +++++++++++- crates/router/src/workflows/api_key_expiry.rs | 23 +++- .../down.sql | 6 + .../up.sql | 6 + 27 files changed, 687 insertions(+), 89 deletions(-) create mode 100644 migrations/2024-12-05-131123_add-email-theme-data-in-themes/down.sql create mode 100644 migrations/2024-12-05-131123_add-email-theme-data-in-themes/up.sql diff --git a/config/config.example.toml b/config/config.example.toml index 5aa9e543fb22..0fe6b8caa2c7 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -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 diff --git a/config/deployments/env_specific.toml b/config/deployments/env_specific.toml index 5cadc66ddfc5..848a2305ae46 100644 --- a/config/deployments/env_specific.toml +++ b/config/deployments/env_specific.toml @@ -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 diff --git a/config/development.toml b/config/development.toml index fa577a78e6ad..077e09f04635 100644 --- a/config/development.toml +++ b/config/development.toml @@ -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 diff --git a/config/docker_compose.toml b/config/docker_compose.toml index 3cc690203e68..dbd5286b2900 100644 --- a/config/docker_compose.toml +++ b/config/docker_compose.toml @@ -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 diff --git a/crates/api_models/src/user.rs b/crates/api_models/src/user.rs index e61696803a20..7b5911cf1a84 100644 --- a/crates/api_models/src/user.rs +++ b/crates/api_models/src/user.rs @@ -150,6 +150,7 @@ pub struct GetUserDetailsResponse { pub recovery_codes_left: Option, pub profile_id: id_type::ProfileId, pub entity_type: EntityType, + pub theme_id: Option, } #[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, + pub theme_id: Option, } #[derive(Debug, serde::Deserialize, serde::Serialize)] diff --git a/crates/api_models/src/user/theme.rs b/crates/api_models/src/user/theme.rs index 23218c4a1633..c79da307b9a8 100644 --- a/crates/api_models/src/user/theme.rs +++ b/crates/api_models/src/user/theme.rs @@ -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, pub merchant_id: Option, pub profile_id: Option, + 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, } #[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, diff --git a/crates/common_utils/src/types/theme.rs b/crates/common_utils/src/types/theme.rs index 239cffc40d45..05ae26b565a7 100644 --- a/crates/common_utils/src/types/theme.rs +++ b/crates/common_utils/src/types/theme.rs @@ -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 { + 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, } diff --git a/crates/diesel_models/src/query/user/theme.rs b/crates/diesel_models/src/query/user/theme.rs index a26e401ffbb3..945ce1a9bb22 100644 --- a/crates/diesel_models/src/query/user/theme.rs +++ b/crates/diesel_models/src/query/user/theme.rs @@ -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 { + let query = ::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::(&query).to_string()); + + let data: Vec = match track_database_call::( + 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, diff --git a/crates/diesel_models/src/schema.rs b/crates/diesel_models/src/schema.rs index 782bdb1b9d38..178f5600542a 100644 --- a/crates/diesel_models/src/schema.rs +++ b/crates/diesel_models/src/schema.rs @@ -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, } } diff --git a/crates/diesel_models/src/schema_v2.rs b/crates/diesel_models/src/schema_v2.rs index cb1eaad64f4a..da2298d934bb 100644 --- a/crates/diesel_models/src/schema_v2.rs +++ b/crates/diesel_models/src/schema_v2.rs @@ -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, } } diff --git a/crates/diesel_models/src/user/theme.rs b/crates/diesel_models/src/user/theme.rs index e43a182126b7..46cc90a45eca 100644 --- a/crates/diesel_models/src/user/theme.rs +++ b/crates/diesel_models/src/user/theme.rs @@ -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(), } } } diff --git a/crates/router/src/configs/secrets_transformers.rs b/crates/router/src/configs/secrets_transformers.rs index 3ab56266b555..3862f70536fc 100644 --- a/crates/router/src/configs/secrets_transformers.rs +++ b/crates/router/src/configs/secrets_transformers.rs @@ -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, } } diff --git a/crates/router/src/configs/settings.rs b/crates/router/src/configs/settings.rs index 1da4a33f5f33..4ab2a8fd5c77 100644 --- a/crates/router/src/configs/settings.rs +++ b/crates/router/src/configs/settings.rs @@ -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 { pub network_tokenization_supported_card_networks: NetworkTokenizationSupportedCardNetworks, pub network_tokenization_service: Option>, pub network_tokenization_supported_connectors: NetworkTokenizationSupportedConnectors, - pub theme_storage: FileStorageConfig, + pub theme: ThemeSettings, } #[derive(Debug, Deserialize, Clone, Default)] @@ -887,7 +887,8 @@ impl Settings { .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( value: HashMap, ) -> Result>, String> diff --git a/crates/router/src/core/errors/user.rs b/crates/router/src/core/errors/user.rs index 33477df6330a..fa5f185ab824 100644 --- a/crates/router/src/core/errors/user.rs +++ b/crates/router/src/core/errors/user.rs @@ -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 for UserErrors { @@ -275,6 +277,9 @@ impl common_utils::errors::ErrorSwitch { 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(), } } } diff --git a/crates/router/src/core/recon.rs b/crates/router/src/core/recon.rs index 13cf4c488ec5..c2f289d4cc02 100644 --- a/crates/router/src/core/recon.rs +++ b/crates/router/src/core/recon.rs @@ -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 diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs index 629476b2591e..642473bdf8e0 100644 --- a/crates/router/src/core/user.rs +++ b/crates/router/src/core/user.rs @@ -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, + theme_id: Option, ) -> UserResponse { 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, + theme_id: Option, ) -> UserResponse { 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, + theme_id: Option, ) -> 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, + theme_id: Option, ) -> 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 diff --git a/crates/router/src/core/user/dashboard_metadata.rs b/crates/router/src/core/user/dashboard_metadata.rs index a64b0f39dc03..88380363b4e3 100644 --- a/crates/router/src/core/user/dashboard_metadata.rs +++ b/crates/router/src/core/user/dashboard_metadata.rs @@ -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( diff --git a/crates/router/src/core/user/theme.rs b/crates/router/src/core/user/theme.rs index dbf11256a502..27b342d90538 100644 --- a/crates/router/src/core/user/theme.rs +++ b/crates/router/src/core/user/theme.rs @@ -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_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, diff --git a/crates/router/src/db/kafka_store.rs b/crates/router/src/db/kafka_store.rs index 2fd30a3610b5..a7e71284aafd 100644 --- a/crates/router/src/db/kafka_store.rs +++ b/crates/router/src/db/kafka_store.rs @@ -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 { + self.diesel_store + .find_most_specific_theme_in_lineage(lineage) + .await + } + async fn find_theme_by_lineage( &self, lineage: ThemeLineage, diff --git a/crates/router/src/db/user/theme.rs b/crates/router/src/db/user/theme.rs index 9b55a98d0ad3..51c0a52b3a01 100644 --- a/crates/router/src/db/user/theme.rs +++ b/crates/router/src/db/user/theme.rs @@ -21,6 +21,11 @@ pub trait ThemeInterface { theme_id: String, ) -> CustomResult; + async fn find_most_specific_theme_in_lineage( + &self, + lineage: ThemeLineage, + ) -> CustomResult; + 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 { + 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 { + 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, diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs index ca7c4847ad4a..5f04581fbb39 100644 --- a/crates/router/src/routes/app.rs +++ b/crates/router/src/routes/app.rs @@ -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; diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs index c32746c87185..d075048ddb6b 100644 --- a/crates/router/src/routes/user.rs +++ b/crates/router/src/routes/user.rs @@ -41,18 +41,23 @@ pub async fn user_signup_with_merchant_id( state: web::Data, http_req: HttpRequest, json_payload: web::Json, - query: web::Query, + query: web::Query, ) -> 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, http_req: HttpRequest, json_payload: web::Json, - query: web::Query, + query: web::Query, ) -> 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, req: HttpRequest, payload: web::Json, - query: web::Query, + query: web::Query, ) -> 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, req: HttpRequest, payload: web::Json>, - auth_id_query_param: web::Query, + auth_id_query_param: web::Query, ) -> 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, req: HttpRequest, payload: web::Json, - query: web::Query, + query: web::Query, ) -> 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, http_req: HttpRequest, json_payload: web::Json, - query: web::Query, + query: web::Query, ) -> 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, diff --git a/crates/router/src/services/email/types.rs b/crates/router/src/services/email/types.rs index 66e730f08246..128250a61aa8 100644 --- a/crates/router/src/services/email/types.rs +++ b/crates/router/src/services/email/types.rs @@ -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, + theme_id: &Option, ) -> 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, pub subject: &'static str, pub auth_id: Option, + pub theme_id: Option, + 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, pub subject: &'static str, pub auth_id: Option, + pub theme_id: Option, + 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, pub subject: &'static str, pub auth_id: Option, + pub theme_id: Option, + 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, + pub theme_id: Option, + 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, + pub theme_config: EmailThemeConfig, } #[async_trait::async_trait] @@ -410,10 +428,17 @@ pub struct BizEmailProd { pub business_website: String, pub settings: std::sync::Arc, pub subject: &'static str, + pub theme_id: Option, + pub theme_config: EmailThemeConfig, } impl BizEmailProd { - pub fn new(state: &SessionState, data: ProdIntent) -> UserResult { + pub fn new( + state: &SessionState, + data: ProdIntent, + theme_id: Option, + theme_config: EmailThemeConfig, + ) -> UserResult { 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, + 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, + pub theme_config: EmailThemeConfig, } #[async_trait::async_trait] diff --git a/crates/router/src/utils/user/theme.rs b/crates/router/src/utils/user/theme.rs index 13452380d9d9..1c8b76989ae8 100644 --- a/crates/router/src/utils/user/theme.rs +++ b/crates/router/src/utils/user/theme.rs @@ -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> { + 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> { + 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, +) -> UserResult> { + 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)) + } + } + } } diff --git a/crates/router/src/workflows/api_key_expiry.rs b/crates/router/src/workflows/api_key_expiry.rs index bf6100179bf2..cdc4959c4562 100644 --- a/crates/router/src/workflows/api_key_expiry.rs +++ b/crates/router/src/workflows/api_key_expiry.rs @@ -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 for ApiKeyExpiryWorkflow { let email_id = merchant_account .merchant_details + .clone() .parse_value::("MerchantDetails")? .primary_email .ok_or(errors::ProcessTrackerError::EValidationError( @@ -73,6 +74,20 @@ impl ProcessTrackerWorkflow 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 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 diff --git a/migrations/2024-12-05-131123_add-email-theme-data-in-themes/down.sql b/migrations/2024-12-05-131123_add-email-theme-data-in-themes/down.sql new file mode 100644 index 000000000000..34732b5dd695 --- /dev/null +++ b/migrations/2024-12-05-131123_add-email-theme-data-in-themes/down.sql @@ -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; diff --git a/migrations/2024-12-05-131123_add-email-theme-data-in-themes/up.sql b/migrations/2024-12-05-131123_add-email-theme-data-in-themes/up.sql new file mode 100644 index 000000000000..1004302ab7f6 --- /dev/null +++ b/migrations/2024-12-05-131123_add-email-theme-data-in-themes/up.sql @@ -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';