From 7b9cd77d042dd49acf185df7ff396e66c5457180 Mon Sep 17 00:00:00 2001 From: RafaelKC Date: Thu, 27 Nov 2025 20:01:25 -0300 Subject: [PATCH 01/14] FIN-76 adding localization support --- .../Users/Services/UserCreateService.cs | 8 +-- Fin.Domain/Tenants/Entities/Tenant.cs | 6 +-- .../Users/Dtos/UserUpdateOrCreateInput.cs | 8 ++- .../Users/TenantConfiguration.cs | 2 +- .../Errors/ExceptionHandlingMiddleware.cs | 15 ++---- .../Extensions/UserMiddlewaresExtension.cs | 2 + .../Localizations/LocalizationMiddleware.cs | 39 +++++++++++++++ .../Localizations/LocalizationService.cs | 50 +++++++++++++++++++ 8 files changed, 109 insertions(+), 21 deletions(-) create mode 100644 Fin.Infrastructure/Localizations/LocalizationMiddleware.cs create mode 100644 Fin.Infrastructure/Localizations/LocalizationService.cs diff --git a/Fin.Application/Users/Services/UserCreateService.cs b/Fin.Application/Users/Services/UserCreateService.cs index 94ee8ec..96d87df 100644 --- a/Fin.Application/Users/Services/UserCreateService.cs +++ b/Fin.Application/Users/Services/UserCreateService.cs @@ -205,7 +205,7 @@ public async Task> CreateUser(string creationToken, var user = new User(input, now); var credential = UserCredentialFactory.Create(user.Id, process.EncryptedEmail, process.EncryptedPassword, UserCredentialFactoryType.Password); - return await ExecuteCreateUser(creationToken, user, credential); + return await ExecuteCreateUser(creationToken, user, credential, input.Timezone, input.Locale); } public async Task> CreateUser(string googleId, string email, UserUpdateOrCreateInput input) @@ -223,12 +223,12 @@ public async Task> CreateUser(string googleId, stri var user = new User(input, now); var credential = UserCredentialFactory.Create(user.Id, encryptedEmail, googleId, UserCredentialFactoryType.Google); - return await ExecuteCreateUser(null, user, credential); + return await ExecuteCreateUser(null, user, credential, input.Timezone, input.Locale); } - private async Task> ExecuteCreateUser(string creationToken, User user, UserCredential credential) + private async Task> ExecuteCreateUser(string creationToken, User user, UserCredential credential, string timezone, string locale) { - var tenant = new Tenant(user.CreatedAt); + var tenant = new Tenant(user.CreatedAt, timezone, locale); user.Tenants.Add(tenant); var notificationSetting = new UserNotificationSettings(user.Id, tenant.Id); diff --git a/Fin.Domain/Tenants/Entities/Tenant.cs b/Fin.Domain/Tenants/Entities/Tenant.cs index 3169612..e838a60 100644 --- a/Fin.Domain/Tenants/Entities/Tenant.cs +++ b/Fin.Domain/Tenants/Entities/Tenant.cs @@ -18,12 +18,12 @@ public Tenant() { } - public Tenant(DateTime now) + public Tenant(DateTime now, string timezone, string locale) { Id = Guid.NewGuid(); CreatedAt = now; UpdatedAt = now; - Locale = "pt-Br"; - Timezone = "America/Sao_Paulo"; + Locale = locale ?? "pt-BR"; + Timezone = timezone ?? "America/Sao_Paulo"; } } \ No newline at end of file diff --git a/Fin.Domain/Users/Dtos/UserUpdateOrCreateInput.cs b/Fin.Domain/Users/Dtos/UserUpdateOrCreateInput.cs index d2ca065..0ad1f31 100644 --- a/Fin.Domain/Users/Dtos/UserUpdateOrCreateInput.cs +++ b/Fin.Domain/Users/Dtos/UserUpdateOrCreateInput.cs @@ -12,11 +12,17 @@ public class UserUpdateOrCreateInput public string LastName { get; set; } [Required] [MinLength(2)] - [MaxLength(150)] public string DisplayName { get; set; } public UserGender Gender { get; set; } public DateOnly? BirthDate { get; set; } public string ImagePublicUrl { get; set; } + + [MaxLength(5)] + [MinLength(5)] + public string Locale { get; set; } + + [MaxLength(40)] + public string Timezone { get; set; } } \ No newline at end of file diff --git a/Fin.Infrastructure/Database/Configurations/Users/TenantConfiguration.cs b/Fin.Infrastructure/Database/Configurations/Users/TenantConfiguration.cs index fa7cc32..1240b3e 100644 --- a/Fin.Infrastructure/Database/Configurations/Users/TenantConfiguration.cs +++ b/Fin.Infrastructure/Database/Configurations/Users/TenantConfiguration.cs @@ -11,7 +11,7 @@ public void Configure(EntityTypeBuilder builder) { builder.HasKey(x => x.Id); - builder.Property(x => x.Locale).HasMaxLength(30); + builder.Property(x => x.Locale).HasMaxLength(5); builder.Property(x => x.Timezone).HasMaxLength(30); builder diff --git a/Fin.Infrastructure/Errors/ExceptionHandlingMiddleware.cs b/Fin.Infrastructure/Errors/ExceptionHandlingMiddleware.cs index 1ae5951..93dafc5 100644 --- a/Fin.Infrastructure/Errors/ExceptionHandlingMiddleware.cs +++ b/Fin.Infrastructure/Errors/ExceptionHandlingMiddleware.cs @@ -5,22 +5,13 @@ namespace Fin.Infrastructure.Errors; -public class ExceptionHandlingMiddleware +public class ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) { - private readonly RequestDelegate _next; - private readonly ILogger _logger; - - public ExceptionHandlingMiddleware(RequestDelegate next, ILogger logger) - { - _next = next; - _logger = logger; - } - public async Task Invoke(HttpContext context) { try { - await _next(context); + await next(context); } catch (Exception ex) { @@ -47,7 +38,7 @@ public async Task Invoke(HttpContext context) break; default: context.Response.StatusCode = StatusCodes.Status500InternalServerError; - _logger.LogError(ex, "Unhandled exception"); + logger.LogError(ex, "Unhandled exception"); error = "An unexpected error occurred."; break; } diff --git a/Fin.Infrastructure/Extensions/UserMiddlewaresExtension.cs b/Fin.Infrastructure/Extensions/UserMiddlewaresExtension.cs index 2f1d1b1..abc39e6 100644 --- a/Fin.Infrastructure/Extensions/UserMiddlewaresExtension.cs +++ b/Fin.Infrastructure/Extensions/UserMiddlewaresExtension.cs @@ -1,6 +1,7 @@ using Fin.Infrastructure.AmbientDatas; using Fin.Infrastructure.Authentications; using Fin.Infrastructure.Errors; +using Fin.Infrastructure.Localizations; using Microsoft.AspNetCore.Builder; namespace Fin.Infrastructure.Extensions; @@ -13,6 +14,7 @@ public static WebApplication UseFinMiddlewares(this WebApplication app) app.UseMiddleware(); app.UseMiddleware(); app.UseMiddleware(); + app.UseMiddleware(); return app; } diff --git a/Fin.Infrastructure/Localizations/LocalizationMiddleware.cs b/Fin.Infrastructure/Localizations/LocalizationMiddleware.cs new file mode 100644 index 0000000..20b3bbc --- /dev/null +++ b/Fin.Infrastructure/Localizations/LocalizationMiddleware.cs @@ -0,0 +1,39 @@ +using System.Globalization; +using Fin.Domain.Tenants.Entities; +using Fin.Infrastructure.AmbientDatas; +using Fin.Infrastructure.Database.Repositories; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Fin.Infrastructure.Localizations; + +public class LocalizationMiddleware(RequestDelegate next, ILogger logger) +{ + public async Task InvokeAsync(HttpContext context, IAmbientData ambientData, IRepository tenantRepository) + { + if (ambientData.IsLogged) + { + var tenant = await tenantRepository.AsNoTracking().FirstAsync(tenant => tenant.Id == ambientData.TenantId); + var locale = tenant.Locale; + + if (!string.IsNullOrWhiteSpace(locale)) + { + try + { + var culture = new CultureInfo(locale); + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + } + catch (CultureNotFoundException) + { + logger.LogWarning("Culture '{Locale}' not found. Falling back to 'en-US'. TenantId: {TenantId}", locale, tenant.Id); + var fallback = new CultureInfo("en-US"); + CultureInfo.CurrentCulture = fallback; + CultureInfo.CurrentUICulture = fallback; + } + } + } + await next(context); + } +} \ No newline at end of file diff --git a/Fin.Infrastructure/Localizations/LocalizationService.cs b/Fin.Infrastructure/Localizations/LocalizationService.cs new file mode 100644 index 0000000..62501b0 --- /dev/null +++ b/Fin.Infrastructure/Localizations/LocalizationService.cs @@ -0,0 +1,50 @@ +using Fin.Domain.Tenants.Entities; +using Fin.Infrastructure.AmbientDatas; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Repositories; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Fin.Infrastructure.Localizations; + +public interface ILocalizationService +{ + public Task FormatToUserDateTime(DateTime dateTime, bool showTime = true, bool showLong = false); +} + +public class LocalizationService(IAmbientData ambientData, IRepository tenantsRepository, ILogger logger): ILocalizationService, IAutoTransient +{ + public async Task FormatToUserDateTime(DateTime dateTime, bool showTime = true, bool showLong = false) + { + var formt = "f"; + if (!showTime) + { + formt = "d"; + } + else if (!showLong) + { + formt = "g"; + } + + var timeZoneInfo = TimeZoneInfo.Utc; + if (ambientData.IsLogged) + { + var tenant = await tenantsRepository.FirstAsync(t => t.Id == ambientData.TenantId); + try + { + if (!string.IsNullOrWhiteSpace(tenant.Timezone)) + { + timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(tenant.Locale); + + } + } + catch (TimeZoneNotFoundException) + { + logger.LogWarning("TimeZone {TimeZone} invalid for Tenant Id {TenantId}. Falling back to 'UTC'", tenant.Timezone, tenant.Id); + } + } + + var localDate = TimeZoneInfo.ConvertTimeFromUtc(dateTime, timeZoneInfo); + return localDate.ToString(formt); + } +} \ No newline at end of file From 1a037f791d722af1669c2f6fd4547783b2ba0339 Mon Sep 17 00:00:00 2001 From: RafaelKC Date: Thu, 27 Nov 2025 20:05:55 -0300 Subject: [PATCH 02/14] FIN-76 adjusting tenant localization columns --- .../Users/TenantConfiguration.cs | 2 +- ...ng_tenant_lozalization_columns.Designer.cs | 1035 +++++++++++++++++ ...0_adjusting_tenant_lozalization_columns.cs | 66 ++ .../Migrations/FinDbContextModelSnapshot.cs | 8 +- 4 files changed, 1106 insertions(+), 5 deletions(-) create mode 100644 Fin.Infrastructure/Migrations/20251127230450_adjusting_tenant_lozalization_columns.Designer.cs create mode 100644 Fin.Infrastructure/Migrations/20251127230450_adjusting_tenant_lozalization_columns.cs diff --git a/Fin.Infrastructure/Database/Configurations/Users/TenantConfiguration.cs b/Fin.Infrastructure/Database/Configurations/Users/TenantConfiguration.cs index 1240b3e..91e4502 100644 --- a/Fin.Infrastructure/Database/Configurations/Users/TenantConfiguration.cs +++ b/Fin.Infrastructure/Database/Configurations/Users/TenantConfiguration.cs @@ -12,7 +12,7 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(x => x.Id); builder.Property(x => x.Locale).HasMaxLength(5); - builder.Property(x => x.Timezone).HasMaxLength(30); + builder.Property(x => x.Timezone).HasMaxLength(40); builder .HasMany(e => e.Users) diff --git a/Fin.Infrastructure/Migrations/20251127230450_adjusting_tenant_lozalization_columns.Designer.cs b/Fin.Infrastructure/Migrations/20251127230450_adjusting_tenant_lozalization_columns.Designer.cs new file mode 100644 index 0000000..2d916ff --- /dev/null +++ b/Fin.Infrastructure/Migrations/20251127230450_adjusting_tenant_lozalization_columns.Designer.cs @@ -0,0 +1,1035 @@ +// +using System; +using Fin.Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Fin.Infrastructure.Migrations +{ + [DbContext(typeof(FinDbContext))] + [Migration("20251127230450_adjusting_tenant_lozalization_columns")] + partial class adjusting_tenant_lozalization_columns + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "9.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Fin.Domain.CardBrands.Entities.CardBrand", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("CardBrands", "public"); + }); + + modelBuilder.Entity("Fin.Domain.CreditCards.Entities.CreditCard", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CardBrandId") + .HasColumnType("uuid"); + + b.Property("ClosingDay") + .HasColumnType("integer"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DebitWalletId") + .HasColumnType("uuid"); + + b.Property("DueDay") + .HasColumnType("integer"); + + b.Property("FinancialInstitutionId") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inactivated") + .HasColumnType("boolean"); + + b.Property("Limit") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CardBrandId"); + + b.HasIndex("DebitWalletId"); + + b.HasIndex("FinancialInstitutionId"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("CreditCards", "public"); + }); + + modelBuilder.Entity("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inactive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .IsUnicode(true) + .HasColumnType("character varying(100)"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("FinancialInstitution", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Menus.Entities.Menu", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("FrontRoute") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("KeyWords") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OnlyForAdmin") + .HasColumnType("boolean"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Menus", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.Notification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Continuous") + .HasColumnType("boolean"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("HtmlBody") + .HasColumnType("text"); + + b.Property("Link") + .HasColumnType("text"); + + b.Property("NormalizedTextBody") + .HasColumnType("text"); + + b.Property("NormalizedTitle") + .HasColumnType("text"); + + b.Property("Severity") + .HasColumnType("integer"); + + b.Property("StartToDelivery") + .HasColumnType("timestamp with time zone"); + + b.Property("StopToDelivery") + .HasColumnType("timestamp with time zone"); + + b.Property("TextBody") + .HasColumnType("text"); + + b.Property("Title") + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("Ways") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Notifications", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.NotificationUserDelivery", b => + { + b.Property("NotificationId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("BackgroundJobId") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Delivery") + .HasColumnType("boolean"); + + b.Property("Visualized") + .HasColumnType("boolean"); + + b.HasKey("NotificationId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("NotificationUserDeliveries", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserNotificationSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowedWays") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Enabled") + .HasColumnType("boolean"); + + b.Property("FirebaseTokens") + .HasColumnType("text"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserNotificationSettings", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserRememberUseSetting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("NotifyOn") + .HasColumnType("interval"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Ways") + .HasColumnType("text"); + + b.Property("WeekDays") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserRememberUseSettings", "public"); + }); + + modelBuilder.Entity("Fin.Domain.People.Entities.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Inactivated") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("Person", "public"); + }); + + modelBuilder.Entity("Fin.Domain.People.Entities.TitlePerson", b => + { + b.Property("PersonId") + .HasColumnType("uuid"); + + b.Property("TitleId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Percentage") + .HasPrecision(5, 2) + .HasColumnType("numeric(5,2)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("PersonId", "TitleId"); + + b.HasIndex("TitleId"); + + b.ToTable("TitlePerson", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.Tenant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Locale") + .HasMaxLength(5) + .HasColumnType("character varying(5)"); + + b.Property("Timezone") + .HasMaxLength(40) + .HasColumnType("character varying(40)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Tenants", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.TenantUser", b => + { + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("TenantId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("TenantUsers", "public"); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleCategory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inactivated") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("TitleCategories", "public"); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleTitleCategory", b => + { + b.Property("TitleCategoryId") + .HasColumnType("uuid"); + + b.Property("TitleId") + .HasColumnType("uuid"); + + b.HasKey("TitleCategoryId", "TitleId"); + + b.HasIndex("TitleId"); + + b.ToTable("TitleTitleCategories", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PreviousBalance") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("Value") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property("WalletId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("WalletId"); + + b.ToTable("Titles", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("FirstName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("ImagePublicUrl") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("IsActivity") + .HasColumnType("boolean"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("LastName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Users", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EncryptedEmail") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("EncryptedPassword") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("FailLoginAttempts") + .HasColumnType("integer"); + + b.Property("GoogleId") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ResetToken") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EncryptedEmail") + .IsUnique(); + + b.HasIndex("GoogleId") + .IsUnique(); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("Credentials", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserDeleteRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Aborted") + .HasColumnType("boolean"); + + b.Property("AbortedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("DeleteEffectivatedAt") + .HasColumnType("date"); + + b.Property("DeleteRequestedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.Property("UserAbortedId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserAbortedId"); + + b.HasIndex("UserId"); + + b.ToTable("UserDeleteRequests", "public"); + }); + + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Color") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedBy") + .HasColumnType("uuid"); + + b.Property("FinancialInstitutionId") + .HasColumnType("uuid"); + + b.Property("Icon") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Inactivated") + .HasColumnType("boolean"); + + b.Property("InitialBalance") + .HasPrecision(19, 4) + .HasColumnType("numeric(19,4)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TenantId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedBy") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("FinancialInstitutionId"); + + b.HasIndex("Name", "TenantId") + .IsUnique(); + + b.ToTable("Wallets", "public"); + }); + + modelBuilder.Entity("Fin.Domain.CreditCards.Entities.CreditCard", b => + { + b.HasOne("Fin.Domain.CardBrands.Entities.CardBrand", "CardBrand") + .WithMany("CreditCards") + .HasForeignKey("CardBrandId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Fin.Domain.Wallets.Entities.Wallet", "DebitWallet") + .WithMany("CreditCards") + .HasForeignKey("DebitWalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", "FinancialInstitution") + .WithMany("CreditCards") + .HasForeignKey("FinancialInstitutionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("CardBrand"); + + b.Navigation("DebitWallet"); + + b.Navigation("FinancialInstitution"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.NotificationUserDelivery", b => + { + b.HasOne("Fin.Domain.Notifications.Entities.Notification", "Notification") + .WithMany("UserDeliveries") + .HasForeignKey("NotificationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Notification"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserNotificationSettings", b => + { + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.UserRememberUseSetting", b => + { + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Fin.Domain.People.Entities.TitlePerson", b => + { + b.HasOne("Fin.Domain.People.Entities.Person", "Person") + .WithMany("TitlePeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Titles.Entities.Title", "Title") + .WithMany("TitlePeople") + .HasForeignKey("TitleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("Title"); + }); + + modelBuilder.Entity("Fin.Domain.Tenants.Entities.TenantUser", b => + { + b.HasOne("Fin.Domain.Tenants.Entities.Tenant", null) + .WithMany() + .HasForeignKey("TenantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Users.Entities.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleTitleCategory", b => + { + b.HasOne("Fin.Domain.TitleCategories.Entities.TitleCategory", "TitleCategory") + .WithMany("TitleTitleCategories") + .HasForeignKey("TitleCategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Fin.Domain.Titles.Entities.Title", "Title") + .WithMany("TitleTitleCategories") + .HasForeignKey("TitleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Title"); + + b.Navigation("TitleCategory"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.HasOne("Fin.Domain.Wallets.Entities.Wallet", "Wallet") + .WithMany("Titles") + .HasForeignKey("WalletId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Wallet"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserCredential", b => + { + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithOne("Credential") + .HasForeignKey("Fin.Domain.Users.Entities.UserCredential", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.UserDeleteRequest", b => + { + b.HasOne("Fin.Domain.Users.Entities.User", "UserAborted") + .WithMany() + .HasForeignKey("UserAbortedId"); + + b.HasOne("Fin.Domain.Users.Entities.User", "User") + .WithMany("DeleteRequests") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + + b.Navigation("UserAborted"); + }); + + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.HasOne("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", "FinancialInstitution") + .WithMany("Wallets") + .HasForeignKey("FinancialInstitutionId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("FinancialInstitution"); + }); + + modelBuilder.Entity("Fin.Domain.CardBrands.Entities.CardBrand", b => + { + b.Navigation("CreditCards"); + }); + + modelBuilder.Entity("Fin.Domain.FinancialInstitutions.Entities.FinancialInstitution", b => + { + b.Navigation("CreditCards"); + + b.Navigation("Wallets"); + }); + + modelBuilder.Entity("Fin.Domain.Notifications.Entities.Notification", b => + { + b.Navigation("UserDeliveries"); + }); + + modelBuilder.Entity("Fin.Domain.People.Entities.Person", b => + { + b.Navigation("TitlePeople"); + }); + + modelBuilder.Entity("Fin.Domain.TitleCategories.Entities.TitleCategory", b => + { + b.Navigation("TitleTitleCategories"); + }); + + modelBuilder.Entity("Fin.Domain.Titles.Entities.Title", b => + { + b.Navigation("TitlePeople"); + + b.Navigation("TitleTitleCategories"); + }); + + modelBuilder.Entity("Fin.Domain.Users.Entities.User", b => + { + b.Navigation("Credential"); + + b.Navigation("DeleteRequests"); + }); + + modelBuilder.Entity("Fin.Domain.Wallets.Entities.Wallet", b => + { + b.Navigation("CreditCards"); + + b.Navigation("Titles"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Fin.Infrastructure/Migrations/20251127230450_adjusting_tenant_lozalization_columns.cs b/Fin.Infrastructure/Migrations/20251127230450_adjusting_tenant_lozalization_columns.cs new file mode 100644 index 0000000..5624fdc --- /dev/null +++ b/Fin.Infrastructure/Migrations/20251127230450_adjusting_tenant_lozalization_columns.cs @@ -0,0 +1,66 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Fin.Infrastructure.Migrations +{ + /// + public partial class adjusting_tenant_lozalization_columns : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Timezone", + schema: "public", + table: "Tenants", + type: "character varying(40)", + maxLength: 40, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(30)", + oldMaxLength: 30, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Locale", + schema: "public", + table: "Tenants", + type: "character varying(5)", + maxLength: 5, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(30)", + oldMaxLength: 30, + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Timezone", + schema: "public", + table: "Tenants", + type: "character varying(30)", + maxLength: 30, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(40)", + oldMaxLength: 40, + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "Locale", + schema: "public", + table: "Tenants", + type: "character varying(30)", + maxLength: 30, + nullable: true, + oldClrType: typeof(string), + oldType: "character varying(5)", + oldMaxLength: 5, + oldNullable: true); + } + } +} diff --git a/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs b/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs index 2e25d6f..2c06658 100644 --- a/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs +++ b/Fin.Infrastructure/Migrations/FinDbContextModelSnapshot.cs @@ -476,12 +476,12 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("timestamp with time zone"); b.Property("Locale") - .HasMaxLength(30) - .HasColumnType("character varying(30)"); + .HasMaxLength(5) + .HasColumnType("character varying(5)"); b.Property("Timezone") - .HasMaxLength(30) - .HasColumnType("character varying(30)"); + .HasMaxLength(40) + .HasColumnType("character varying(40)"); b.Property("UpdatedAt") .HasColumnType("timestamp with time zone"); From 96413cea9441750def2dabab43a47eb550bf208e Mon Sep 17 00:00:00 2001 From: RafaelKC Date: Sat, 29 Nov 2025 01:34:02 -0300 Subject: [PATCH 03/14] FIN-76 added endpoint to get tenant and setting culture via header too --- Fin.Api/Tenants/TenantController.cs | 18 +++++ Fin.Application/Tenants/Dtos/TenantOutput.cs | 10 +++ Fin.Application/Tenants/TenantService.cs | 33 +++++++++ .../AmbientDatas/AmbientDataMiddleware.cs | 15 +--- .../Localizations/LocalizationMiddleware.cs | 74 ++++++++++++++----- 5 files changed, 120 insertions(+), 30 deletions(-) create mode 100644 Fin.Api/Tenants/TenantController.cs create mode 100644 Fin.Application/Tenants/Dtos/TenantOutput.cs create mode 100644 Fin.Application/Tenants/TenantService.cs diff --git a/Fin.Api/Tenants/TenantController.cs b/Fin.Api/Tenants/TenantController.cs new file mode 100644 index 0000000..705eb6d --- /dev/null +++ b/Fin.Api/Tenants/TenantController.cs @@ -0,0 +1,18 @@ +using Fin.Application.Tenants; +using Fin.Application.Tenants.Dtos; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Fin.Api.Tenants; + +[Route("tenants")] +[Authorize] +public class TenantController(ITenantService service): ControllerBase +{ + [HttpGet("{id:guid}")] + public async Task> Get([FromRoute] Guid id) + { + var menu = await service.Get(id); + return menu != null ? Ok(menu) : NotFound(); + } +} \ No newline at end of file diff --git a/Fin.Application/Tenants/Dtos/TenantOutput.cs b/Fin.Application/Tenants/Dtos/TenantOutput.cs new file mode 100644 index 0000000..8e22865 --- /dev/null +++ b/Fin.Application/Tenants/Dtos/TenantOutput.cs @@ -0,0 +1,10 @@ +using Fin.Domain.Tenants.Entities; + +namespace Fin.Application.Tenants.Dtos; + +public class TenantOutput(Tenant tenant) +{ + public Guid Id { get; set; } = tenant.Id; + public string Locale { get; set; } = tenant.Locale; + public string Timezone { get; set; } = tenant.Timezone; +} \ No newline at end of file diff --git a/Fin.Application/Tenants/TenantService.cs b/Fin.Application/Tenants/TenantService.cs new file mode 100644 index 0000000..27bf5b8 --- /dev/null +++ b/Fin.Application/Tenants/TenantService.cs @@ -0,0 +1,33 @@ +using System.Security; +using Fin.Application.Tenants.Dtos; +using Fin.Domain.Tenants.Entities; +using Fin.Domain.Users.Dtos; +using Fin.Infrastructure.AmbientDatas; +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Database.Repositories; +using Microsoft.EntityFrameworkCore; + +namespace Fin.Application.Tenants; + +public interface ITenantService +{ + public Task Get(Guid id); +} + +public class TenantService(IRepository tenantsRepository, IAmbientData ambientData): ITenantService, IAutoTransient +{ + public async Task Get(Guid id) + { + var entity = await tenantsRepository + .Include(tenant => tenant.Users ) + .FirstOrDefaultAsync(tenant => tenant.Id == id); + + if (entity == null) return null; + + var userIsOnTenant = entity.Users.Select(user => user.Id).Contains(ambientData.UserId.GetValueOrDefault()); + if (!ambientData.IsAdmin && !userIsOnTenant) + throw new SecurityException("You are not authorized to access this resource"); + + return new TenantOutput(entity); + } +} \ No newline at end of file diff --git a/Fin.Infrastructure/AmbientDatas/AmbientDataMiddleware.cs b/Fin.Infrastructure/AmbientDatas/AmbientDataMiddleware.cs index 09625f4..508a0db 100644 --- a/Fin.Infrastructure/AmbientDatas/AmbientDataMiddleware.cs +++ b/Fin.Infrastructure/AmbientDatas/AmbientDataMiddleware.cs @@ -4,18 +4,11 @@ namespace Fin.Infrastructure.AmbientDatas; -public class AmbientDataMiddleware: IMiddleware +public class AmbientDataMiddleware(IAmbientData ambientData) : IMiddleware { - private readonly IAmbientData _ambientData; - - public AmbientDataMiddleware(IAmbientData ambientData) - { - _ambientData = ambientData; - } - public async Task InvokeAsync(HttpContext context, RequestDelegate next) { - var authHeader = context.Request.Headers["Authorization"].FirstOrDefault(); + var authHeader = context.Request.Headers.Authorization.FirstOrDefault(); if (!string.IsNullOrWhiteSpace(authHeader) && authHeader.StartsWith("Bearer ")) { @@ -32,12 +25,12 @@ public async Task InvokeAsync(HttpContext context, RequestDelegate next) var isAdmin = jwt.Claims.FirstOrDefault(c => c.Type == "role")?.Value == AuthenticationRoles.Admin; var tenantId = jwt.Claims.FirstOrDefault(c => c.Type == "tenantId")?.Value ?? ""; - _ambientData.SetData(Guid.Parse(tenantId), Guid.Parse(userId), displayName, isAdmin); + ambientData.SetData(Guid.Parse(tenantId), Guid.Parse(userId), displayName, isAdmin); } } else { - _ambientData.SetNotLogged(); + ambientData.SetNotLogged(); } await next(context); diff --git a/Fin.Infrastructure/Localizations/LocalizationMiddleware.cs b/Fin.Infrastructure/Localizations/LocalizationMiddleware.cs index 20b3bbc..e7aa8a5 100644 --- a/Fin.Infrastructure/Localizations/LocalizationMiddleware.cs +++ b/Fin.Infrastructure/Localizations/LocalizationMiddleware.cs @@ -14,26 +14,62 @@ public async Task InvokeAsync(HttpContext context, IAmbientData ambientData, IRe { if (ambientData.IsLogged) { - var tenant = await tenantRepository.AsNoTracking().FirstAsync(tenant => tenant.Id == ambientData.TenantId); - var locale = tenant.Locale; - - if (!string.IsNullOrWhiteSpace(locale)) - { - try - { - var culture = new CultureInfo(locale); - CultureInfo.CurrentCulture = culture; - CultureInfo.CurrentUICulture = culture; - } - catch (CultureNotFoundException) - { - logger.LogWarning("Culture '{Locale}' not found. Falling back to 'en-US'. TenantId: {TenantId}", locale, tenant.Id); - var fallback = new CultureInfo("en-US"); - CultureInfo.CurrentCulture = fallback; - CultureInfo.CurrentUICulture = fallback; - } - } + await TrySetLocalizationByTenant(context, ambientData, tenantRepository); } + else + { + TrySetLocalizationByHeader(context); + } + await next(context); } + + private void TrySetLocalizationByHeader(HttpContext context) + { + var locale = context.Request.Headers.AcceptLanguage.FirstOrDefault(); + + if (string.IsNullOrWhiteSpace(locale)) return; + + locale = locale[..5]; + + try + { + var culture = new CultureInfo(locale); + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + } + catch (CultureNotFoundException) + { + logger.LogWarning("Culture '{Locale}' not found from header. Falling back to en-US", locale); + var fallback = new CultureInfo("en-US"); + CultureInfo.CurrentCulture = fallback; + CultureInfo.CurrentUICulture = fallback; + } + } + + private async Task TrySetLocalizationByTenant(HttpContext context, IAmbientData ambientData, + IRepository tenantRepository) + { + var tenant = await tenantRepository.AsNoTracking().FirstAsync(tenant => tenant.Id == ambientData.TenantId); + var locale = tenant.Locale; + + if (string.IsNullOrWhiteSpace(locale)) + { + TrySetLocalizationByHeader(context); + return; + } + + try + { + var culture = new CultureInfo(locale); + CultureInfo.CurrentCulture = culture; + CultureInfo.CurrentUICulture = culture; + } + catch (CultureNotFoundException) + { + logger.LogWarning("Culture '{Locale}' not found on tenantId: {TenantId}. Trying get on header", locale, + tenant.Id); + TrySetLocalizationByHeader(context); + } + } } \ No newline at end of file From c8da3efb9d589b763749b5418560cb2efd402f69 Mon Sep 17 00:00:00 2001 From: RafaelKC Date: Sat, 29 Nov 2025 02:24:06 -0300 Subject: [PATCH 04/14] FIN-77 added email template resorces and added reset password email in 3 supportted langs (en, es, pt) --- Fin-Backend.sln.DotSettings.user | 7 + .../Services/AuthenticationService.cs | 28 +-- .../Utils/AuthenticationTemplates.cs | 155 ---------------- .../Emails/EmailTemplateService.cs | 28 +++ Fin.Application/Fin.Application.csproj | 8 + .../Fin.Application.csproj.DotSettings.user | 2 + .../EmailTemplates/EmailTemplates.Designer.cs | 66 +++++++ .../EmailTemplates/EmailTemplates.es-ES.resx | 168 +++++++++++++++++ .../EmailTemplates/EmailTemplates.pt-br.resx | 168 +++++++++++++++++ .../EmailTemplates/EmailTemplates.resx | 175 ++++++++++++++++++ 10 files changed, 636 insertions(+), 169 deletions(-) create mode 100644 Fin.Application/Emails/EmailTemplateService.cs create mode 100644 Fin.Application/Fin.Application.csproj.DotSettings.user create mode 100644 Fin.Application/Resources/EmailTemplates/EmailTemplates.Designer.cs create mode 100644 Fin.Application/Resources/EmailTemplates/EmailTemplates.es-ES.resx create mode 100644 Fin.Application/Resources/EmailTemplates/EmailTemplates.pt-br.resx create mode 100644 Fin.Application/Resources/EmailTemplates/EmailTemplates.resx diff --git a/Fin-Backend.sln.DotSettings.user b/Fin-Backend.sln.DotSettings.user index 0a010a0..0f1dcb6 100644 --- a/Fin-Backend.sln.DotSettings.user +++ b/Fin-Backend.sln.DotSettings.user @@ -9,6 +9,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -18,6 +19,12 @@ <SessionState ContinuousTestingMode="0" IsActive="True" Name="MailSenderClientTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> <Project Location="/home/rafaelchicovis/git/fin-backend/Fin.Test" Presentation="&lt;Fin.Test&gt;" /> </SessionState> + True + False + True + False + True + True diff --git a/Fin.Application/Authentications/Services/AuthenticationService.cs b/Fin.Application/Authentications/Services/AuthenticationService.cs index a056f6f..786626a 100644 --- a/Fin.Application/Authentications/Services/AuthenticationService.cs +++ b/Fin.Application/Authentications/Services/AuthenticationService.cs @@ -1,6 +1,7 @@ using Fin.Application.Authentications.Dtos; using Fin.Application.Authentications.Enums; using Fin.Application.Authentications.Utils; +using Fin.Application.Emails; using Fin.Application.Globals.Dtos; using Fin.Application.Users.Services; using Fin.Domain.Global; @@ -42,6 +43,7 @@ public class AuthenticationService : IAuthenticationService, IAutoTransient private readonly IUserCreateService _userCreateService; private readonly IAuthenticationTokenService _tokenService; private readonly IConfiguration _configuration; + private readonly IEmailTemplateService _emailTemplateService; private readonly CryptoHelper _cryptoHelper; @@ -51,7 +53,8 @@ public AuthenticationService( IEmailSenderService emailSender, IConfiguration configuration, IAuthenticationTokenService tokenService, - IUserCreateService userCreateService) + IUserCreateService userCreateService, + IEmailTemplateService emailTemplateService) { _credentialRepository = credentialRepository; _cache = cache; @@ -59,6 +62,7 @@ public AuthenticationService( _configuration = configuration; _tokenService = tokenService; _userCreateService = userCreateService; + _emailTemplateService = emailTemplateService; var encryptKey = configuration.GetSection(AuthenticationConstants.EncryptKeyConfigKey).Value ?? ""; var encryptIv = configuration.GetSection(AuthenticationConstants.EncryptIvConfigKey).Value ?? ""; @@ -90,19 +94,15 @@ public async Task SendResetPasswordEmail(SendResetPasswordEmailInput input) var logoIconUrl = $"{frontUrl}/icons/fin.png"; var resetLink = $"{frontUrl}/authentication/reset-password?token={token}"; - var subject = AuthenticationTemplates.ResetPasswordEmailSubject - .Replace("{{appName}}", AppConstants.AppName); - - var plainBody = AuthenticationTemplates.ResetPasswordEmailPlainTemplate - .Replace("{{appName}}", AppConstants.AppName) - .Replace("{{linkLifeTime}}", tokenLifeTimeInHours.ToString()) - .Replace("{{resetLink}}", resetLink); - - var htmlBody = AuthenticationTemplates.ResetPasswordEmailTemplate - .Replace("{{appName}}", AppConstants.AppName) - .Replace("{{logoIconUrl}}", logoIconUrl) - .Replace("{{linkLifeTime}}", tokenLifeTimeInHours.ToString()) - .Replace("{{resetLink}}", resetLink); + var parameters = new Dictionary(); + parameters.Add("appName", AppConstants.AppName); + parameters.Add("linkLifeTime", tokenLifeTimeInHours.ToString()); + parameters.Add("resetLink", resetLink); + parameters.Add("logoIconUrl", logoIconUrl); + + var subject = _emailTemplateService.Get("ResetPassword_Subject", parameters); + var plainBody = _emailTemplateService.Get("ResetPassword_Plain", parameters); + var htmlBody = _emailTemplateService.Get("ResetPassword_HTML", parameters); await _emailSender.SendEmailAsync(new SendEmailDto { diff --git a/Fin.Application/Authentications/Utils/AuthenticationTemplates.cs b/Fin.Application/Authentications/Utils/AuthenticationTemplates.cs index c67ce2d..c2e55cc 100644 --- a/Fin.Application/Authentications/Utils/AuthenticationTemplates.cs +++ b/Fin.Application/Authentications/Utils/AuthenticationTemplates.cs @@ -109,160 +109,5 @@ public static class AuthenticationTemplates -"; - - public const string ResetPasswordEmailSubject = "{{appName}} - Reset Your Password"; - - public const string ResetPasswordEmailPlainTemplate = @" -{{appName}} - Password Reset -We received a request to reset your password. -To create a new password, please copy and paste the link below into your browser: -{{resetLink}} -This link expires in {{linkLifeTime}} hours. -If you didn't request this, please ignore this email. -"; - - public const string ResetPasswordEmailTemplate = @" - - - - - - Reset Your Password - - - -
-
-
- {{appName}} logo -
-

{{appName}}

-
- -
-

Reset Your Password

- -

- We received a request to reset your password. Click the button below to create a new password. -

- - Reset Password - - -
- - -
- - "; } \ No newline at end of file diff --git a/Fin.Application/Emails/EmailTemplateService.cs b/Fin.Application/Emails/EmailTemplateService.cs new file mode 100644 index 0000000..02d08bb --- /dev/null +++ b/Fin.Application/Emails/EmailTemplateService.cs @@ -0,0 +1,28 @@ +using Fin.Application.Resources.EmailTemplates; +using Fin.Infrastructure.AutoServices.Interfaces; + +namespace Fin.Application.Emails; + +public interface IEmailTemplateService +{ + public string Get(string key); + public string Get(string key, Dictionary parameters); +} + +public class EmailTemplateService: IEmailTemplateService, IAutoTransient +{ + public string Get(string key) + { + return EmailTemplates.ResourceManager.GetString(key); + } + + public string Get(string key, Dictionary parameters) + { + var template = Get(key); + foreach (var parameter in parameters) + { + template = template.Replace("{{" + parameter.Key +"}}", parameter.Value); + } + return template; + } +} \ No newline at end of file diff --git a/Fin.Application/Fin.Application.csproj b/Fin.Application/Fin.Application.csproj index 9d6071e..2ed4d2f 100644 --- a/Fin.Application/Fin.Application.csproj +++ b/Fin.Application/Fin.Application.csproj @@ -23,4 +23,12 @@ + + + True + True + EmailTemplates.resx + + + diff --git a/Fin.Application/Fin.Application.csproj.DotSettings.user b/Fin.Application/Fin.Application.csproj.DotSettings.user new file mode 100644 index 0000000..9f047f1 --- /dev/null +++ b/Fin.Application/Fin.Application.csproj.DotSettings.user @@ -0,0 +1,2 @@ + + 1F2A1D01-85F1-46DC-A5BD-03B7FCF47B82/d:Resources/d:EmailTemplates/f:EmailTemplates.resx \ No newline at end of file diff --git a/Fin.Application/Resources/EmailTemplates/EmailTemplates.Designer.cs b/Fin.Application/Resources/EmailTemplates/EmailTemplates.Designer.cs new file mode 100644 index 0000000..78b04ff --- /dev/null +++ b/Fin.Application/Resources/EmailTemplates/EmailTemplates.Designer.cs @@ -0,0 +1,66 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Fin.Application.Resources.EmailTemplates { + using System; + + + [System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [System.Diagnostics.DebuggerNonUserCodeAttribute()] + [System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class EmailTemplates { + + private static System.Resources.ResourceManager resourceMan; + + private static System.Globalization.CultureInfo resourceCulture; + + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal EmailTemplates() { + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Resources.ResourceManager ResourceManager { + get { + if (object.Equals(null, resourceMan)) { + System.Resources.ResourceManager temp = new System.Resources.ResourceManager("Fin.Application.Resources.EmailTemplates.EmailTemplates", typeof(EmailTemplates).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + [System.ComponentModel.EditorBrowsableAttribute(System.ComponentModel.EditorBrowsableState.Advanced)] + internal static System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + internal static string ResetPassword_Subject { + get { + return ResourceManager.GetString("ResetPassword_Subject", resourceCulture); + } + } + + internal static string ResetPassword_Plain { + get { + return ResourceManager.GetString("ResetPassword_Plain", resourceCulture); + } + } + + internal static string ResetPassword_HTML { + get { + return ResourceManager.GetString("ResetPassword_HTML", resourceCulture); + } + } + } +} diff --git a/Fin.Application/Resources/EmailTemplates/EmailTemplates.es-ES.resx b/Fin.Application/Resources/EmailTemplates/EmailTemplates.es-ES.resx new file mode 100644 index 0000000..2ab87ab --- /dev/null +++ b/Fin.Application/Resources/EmailTemplates/EmailTemplates.es-ES.resx @@ -0,0 +1,168 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + <!DOCTYPE html> +<html lang='en'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Restablece tu Contraseña</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + .reset-button { + display: inline-block; + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + color: white; + padding: 15px 30px; + text-decoration: none; + border-radius: 6px; + font-weight: bold; + margin-bottom: 30px; + } + + .plain-link { + background: #f8f9fa; + padding: 15px; + border-radius: 6px; + margin: 20px 0; + text-align: left; + } + + .plain-link p { + margin: 0 0 10px 0; + color: #6c757d; + font-size: 14px; + } + + .plain-link a { + color: #f87b07; + word-break: break-all; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='logo de {{appName}}'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Restablece tu Contraseña</h2> + + <p class='message'> + Recibimos una solicitud para restablecer tu contraseña. Haz clic en el botón de abajo para crear una nueva contraseña. + </p> + + <a href='{{resetLink}}' class='reset-button'>Restablecer Contraseña</a> + + <div class='plain-link'> + <p>O copia y pega este enlace:</p> + <a href='{{resetLink}}'>{{resetLink}}</a> + </div> + </div> + + <div class='footer'> + <p>Este enlace expira en {{linkLifeTime}} horas. Si no solicitaste esto, ignora este correo.</p> + </div> + </div> +</body> +</html> + + + {{appName}} - Restablecimiento de Contraseña +Recibimos una solicitud para restablecer tu contraseña. +Para crear una nueva contraseña, copia y pega el enlace de abajo en tu navegador: +{{resetLink}} +Este enlace expira en {{linkLifeTime}} horas. +Si no solicitaste esto, ignora este correo. + + + {{appName}} - Restablece tu Contraseña + + \ No newline at end of file diff --git a/Fin.Application/Resources/EmailTemplates/EmailTemplates.pt-br.resx b/Fin.Application/Resources/EmailTemplates/EmailTemplates.pt-br.resx new file mode 100644 index 0000000..3eaaf8d --- /dev/null +++ b/Fin.Application/Resources/EmailTemplates/EmailTemplates.pt-br.resx @@ -0,0 +1,168 @@ + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + <!DOCTYPE html> +<html lang='en'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Redefina Sua Senha</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + .reset-button { + display: inline-block; + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + color: white; + padding: 15px 30px; + text-decoration: none; + border-radius: 6px; + font-weight: bold; + margin-bottom: 30px; + } + + .plain-link { + background: #f8f9fa; + padding: 15px; + border-radius: 6px; + margin: 20px 0; + text-align: left; + } + + .plain-link p { + margin: 0 0 10px 0; + color: #6c757d; + font-size: 14px; + } + + .plain-link a { + color: #f87b07; + word-break: break-all; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='logo {{appName}}'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Redefina Sua Senha</h2> + + <p class='message'> + Recebemos uma solicitação para redefinir sua senha. Clique no botão abaixo para criar uma nova senha. + </p> + + <a href='{{resetLink}}' class='reset-button'>Redefinir Senha</a> + + <div class='plain-link'> + <p>Ou copie e cole este link:</p> + <a href='{{resetLink}}'>{{resetLink}}</a> + </div> + </div> + + <div class='footer'> + <p>Este link expira em {{linkLifeTime}} horas. Se você não solicitou isso, ignore este e-mail.</p> + </div> + </div> +</body> +</html> + + + {{appName}} - Password Reset +We received a request to reset your password. +To create a new password, please copy and paste the link below into your browser: +{{resetLink}} +This link expires in {{linkLifeTime}} hours. +If you didn't request this, please ignore this email. + + + {{appName}} - Redefina Sua Senha + + \ No newline at end of file diff --git a/Fin.Application/Resources/EmailTemplates/EmailTemplates.resx b/Fin.Application/Resources/EmailTemplates/EmailTemplates.resx new file mode 100644 index 0000000..fe69b3f --- /dev/null +++ b/Fin.Application/Resources/EmailTemplates/EmailTemplates.resx @@ -0,0 +1,175 @@ + + + + + + + + + + text/microsoft-resx + + + 1.3 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {{appName}} - Reset Your Password + + + {{appName}} - Password Reset +We received a request to reset your password. +To create a new password, please copy and paste the link below into your browser: +{{resetLink}} +This link expires in {{linkLifeTime}} hours. +If you didn't request this, please ignore this email. + + + <!DOCTYPE html> +<html lang='en'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Reset Your Password</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + .reset-button { + display: inline-block; + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + color: white; + padding: 15px 30px; + text-decoration: none; + border-radius: 6px; + font-weight: bold; + margin-bottom: 30px; + } + + .plain-link { + background: #f8f9fa; + padding: 15px; + border-radius: 6px; + margin: 20px 0; + text-align: left; + } + + .plain-link p { + margin: 0 0 10px 0; + color: #6c757d; + font-size: 14px; + } + + .plain-link a { + color: #f87b07; + word-break: break-all; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='{{appName}} logo'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Reset Your Password</h2> + + <p class='message'> + We received a request to reset your password. Click the button below to create a new password. + </p> + + <a href='{{resetLink}}' class='reset-button'>Reset Password</a> + + <div class='plain-link'> + <p>Or copy and paste this link:</p> + <a href='{{resetLink}}'>{{resetLink}}</a> + </div> + </div> + + <div class='footer'> + <p>This link expires in {{linkLifeTime}} hours. If you didn't request this, ignore this email.</p> + </div> + </div> +</body> +</html> + + \ No newline at end of file From be6f7f7e39a1ff2ffd054a1ea22aac365a3c8054 Mon Sep 17 00:00:00 2001 From: RafaelKC Date: Mon, 1 Dec 2025 14:05:10 -0300 Subject: [PATCH 05/14] FIN-77 wip adding more email template translations --- Fin.Application/Emails/EmailSenderService.cs | 49 +++ .../Emails/EmailTemplateService.cs | 15 +- .../NotificationDeliveryService.cs | 3 +- .../EmailTemplates/EmailTemplates.es-ES.resx | 328 +++++++++++++++++ .../EmailTemplates/EmailTemplates.pt-br.resx | 337 +++++++++++++++++- .../EmailTemplates/EmailTemplates.resx | 324 +++++++++++++++++ .../Users/Services/UserCreateService.cs | 29 +- .../Users/Services/UserDeleteService.cs | 19 +- .../Users/Utils/AbortDeleteUserTemplates.cs | 151 -------- .../Users/Utils/CreateUserTemplates.cs | 183 ---------- .../EmailSenders/Dto/SendEmailDto.cs | 25 ++ .../MailKitClient.cs} | 31 +- .../MailKit/MailKitClientExtension.cs | 14 + .../Extensions/AddInfrastructureExtension.cs | 4 +- .../AuthenticationServiceTest.cs | 1 + .../EmailSenders/EmailSenderServiceTest.cs | 1 + .../NotificationDeliveryServiceTest.cs | 3 +- Fin.Test/Users/UserCreateServiceTest.cs | 3 +- Fin.Test/Users/UserDeleteServiceTest.cs | 1 + 19 files changed, 1120 insertions(+), 401 deletions(-) create mode 100644 Fin.Application/Emails/EmailSenderService.cs delete mode 100644 Fin.Application/Users/Utils/AbortDeleteUserTemplates.cs delete mode 100644 Fin.Application/Users/Utils/CreateUserTemplates.cs rename Fin.Infrastructure/EmailSenders/{EmailSenderService.cs => MailKit/MailKitClient.cs} (55%) create mode 100644 Fin.Infrastructure/EmailSenders/MailKit/MailKitClientExtension.cs diff --git a/Fin.Application/Emails/EmailSenderService.cs b/Fin.Application/Emails/EmailSenderService.cs new file mode 100644 index 0000000..48c8cd3 --- /dev/null +++ b/Fin.Application/Emails/EmailSenderService.cs @@ -0,0 +1,49 @@ +using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.EmailSenders.Constants; +using Fin.Infrastructure.EmailSenders.Dto; +using Fin.Infrastructure.EmailSenders.MailKit; +using Fin.Infrastructure.EmailSenders.MailSender; +using Microsoft.Extensions.Configuration; + +namespace Fin.Application.Emails; + +public interface IEmailSenderService +{ + public Task SendEmailAsync(SendEmailDto dto, CancellationToken cancellationToken = default); +} + +public class EmailSenderService( + IConfiguration configuration, + IMailSenderClient mailSenderClient, + IMailKitClient mailKitClient, + IEmailTemplateService emailTemplateService + ) : IEmailSenderService, IAutoTransient +{ + public async Task SendEmailAsync(SendEmailDto dto, CancellationToken cancellationToken = default) + { + PopulateWithTemplates(dto); + + return GetMailService() switch + { + MailServicesConst.MailSender => await mailSenderClient.SendEmailAsync(dto, cancellationToken), + _ => await mailKitClient.SendEmailAsync(dto, cancellationToken) + }; + } + + private string GetMailService() + { + var mailService = configuration.GetSection(MailServicesConst.MailServiceConfigurationKey).Value; + return mailService ?? ""; + } + + private void PopulateWithTemplates(SendEmailDto dto) + { + if (string.IsNullOrEmpty(dto.BaseTemplatesName)) return; + + dto.TemplateProperties ??= new Dictionary(); + + dto.HtmlBody ??= emailTemplateService.Get($"{dto.BaseTemplatesName}HTML", dto.TemplateProperties); + dto.PlainBody ??= emailTemplateService.Get($"{dto.BaseTemplatesName}Plain", dto.TemplateProperties); + dto.Subject ??= emailTemplateService.Get($"{dto.BaseTemplatesName}Subject", dto.TemplateProperties); + } +} \ No newline at end of file diff --git a/Fin.Application/Emails/EmailTemplateService.cs b/Fin.Application/Emails/EmailTemplateService.cs index 02d08bb..8efce57 100644 --- a/Fin.Application/Emails/EmailTemplateService.cs +++ b/Fin.Application/Emails/EmailTemplateService.cs @@ -1,5 +1,7 @@ using Fin.Application.Resources.EmailTemplates; using Fin.Infrastructure.AutoServices.Interfaces; +using Fin.Infrastructure.Constants; +using Microsoft.Extensions.Configuration; namespace Fin.Application.Emails; @@ -9,7 +11,7 @@ public interface IEmailTemplateService public string Get(string key, Dictionary parameters); } -public class EmailTemplateService: IEmailTemplateService, IAutoTransient +public class EmailTemplateService(IConfiguration configuration): IEmailTemplateService, IAutoTransient { public string Get(string key) { @@ -19,10 +21,21 @@ public string Get(string key) public string Get(string key, Dictionary parameters) { var template = Get(key); + + PopulateDefaultParameters(parameters); foreach (var parameter in parameters) { template = template.Replace("{{" + parameter.Key +"}}", parameter.Value); } return template; } + + private void PopulateDefaultParameters(Dictionary parameters) + { + var frontUrl = configuration.GetSection(AppConstants.FrontUrlConfigKey).Get(); + var logoIconUrl = $"{frontUrl}/icons/fin.png"; + + parameters.TryAdd("appName", AppConstants.AppName); + parameters.TryAdd("logoIconUrl", logoIconUrl); + } } \ No newline at end of file diff --git a/Fin.Application/Notifications/Services/DeliveryServices/NotificationDeliveryService.cs b/Fin.Application/Notifications/Services/DeliveryServices/NotificationDeliveryService.cs index 583227f..b01e189 100644 --- a/Fin.Application/Notifications/Services/DeliveryServices/NotificationDeliveryService.cs +++ b/Fin.Application/Notifications/Services/DeliveryServices/NotificationDeliveryService.cs @@ -1,4 +1,5 @@ -using Fin.Domain.Global; +using Fin.Application.Emails; +using Fin.Domain.Global; using Fin.Domain.Notifications.Dtos; using Fin.Domain.Notifications.Entities; using Fin.Domain.Notifications.Enums; diff --git a/Fin.Application/Resources/EmailTemplates/EmailTemplates.es-ES.resx b/Fin.Application/Resources/EmailTemplates/EmailTemplates.es-ES.resx index 2ab87ab..e3d5dcd 100644 --- a/Fin.Application/Resources/EmailTemplates/EmailTemplates.es-ES.resx +++ b/Fin.Application/Resources/EmailTemplates/EmailTemplates.es-ES.resx @@ -165,4 +165,332 @@ Si no solicitaste esto, ignora este correo. {{appName}} - Restablece tu Contraseña + + {{appName}} - Confirma Tu Email + +Para completar tu registro, por favor utiliza el código de confirmación a continuación: + +Código de Confirmación: {{confirmationCode}} + +Cómo usar: +1. Regresa a la aplicación o sitio web. +2. Ingresa el código anterior para activar tu cuenta. + +Importante: Este código expira en 10 minutos. Si no solicitaste este código, por favor ignora este email. + +Si no puedes confirmar tu email, por favor contacta a nuestro equipo de soporte. + + + <!DOCTYPE html> +<html lang='en'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Confirm Your Email</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + .confirmation-code { + display: inline-block; + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + color: white; + padding: 20px 40px; + border-radius: 8px; + font-size: 32px; + font-weight: bold; + letter-spacing: 4px; + margin: 20px 0; + font-family: 'Courier New', monospace; + box-shadow: 0 4px 15px rgba(248, 123, 7, 0.3); + } + + .code-info { + background: #f8f9fa; + padding: 20px; + border-radius: 6px; + margin: 20px 0; + border-left: 4px solid #f87b07; + } + + .code-info p { + margin: 0; + color: #6c757d; + font-size: 14px; + text-align: left; + } + + .security-note { + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 6px; + padding: 15px; + margin: 20px 0; + } + + .security-note p { + margin: 0; + color: #856404; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='{{appName}} logo'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Confirm Your Email</h2> + + <p class='message'> + To complete your registration, please use the confirmation code below: + </p> + + <div class='confirmation-code'> + {{confirmationCode}} + </div> + + <div class='code-info'> + <p><strong>How to use:</strong></p> + <p>1. Return to the app or website</p> + <p>2. Enter the 6-digit code in the requested field</p> + <p>3. Click "Confirm" to activate your account</p> + </div> + + <div class='security-note'> + <p><strong>Important:</strong> This code expires in 10 minutes for security reasons. If you didn't request this code, please ignore this email.</p> + </div> + </div> + + <div class='footer'> + <p>If you're unable to confirm your email, please contact our support team.</p> + <p>This is an automated email, please do not reply to this message.</p> + </div> + </div> +</body> +</html> + + + {{appName}} - Confirmación de Correo Electrónico + + + <!DOCTYPE html> +<html lang='es'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Solicitud de eliminación cancelada</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + /* Estilo de éxito/confirmación positiva */ + .success-box { + background: #d4edda; + border-left: 4px solid #28a745; + padding: 20px; + border-radius: 6px; + margin: 20px 0; + text-align: left; + } + + .success-box h3 { + margin: 0 0 10px 0; + color: #155724; + font-size: 18px; + } + + .success-box p { + margin: 0; + color: #155724; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='{{appName}} logo'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Solicitud de eliminación cancelada</h2> + + <p class='message'> + Hemos recibido su confirmación para cancelar el proceso de eliminación. + </p> + + <div class='success-box'> + <h3>Cuenta Segura</h3> + <p>Su solicitud de eliminación de {{appName}} ha sido cancelada y su cuenta <strong>ya no será eliminada</strong>.</p> + </div> + + <p class='message'> + Puede continuar utilizando nuestros servicios normalmente. + </p> + </div> + + <div class='footer'> + <p>Si no realizó esta acción, por favor contáctenos.</p> + </div> + </div> +</body> +</html> + + + {{appName}} - Solicitud de eliminación cancelada + +Su solicitud de eliminación de {{appName}} ha sido cancelada. + +Su cuenta ya no será eliminada y permanece activa. + + + {{appName}} - Solicitud de eliminación cancelada + \ No newline at end of file diff --git a/Fin.Application/Resources/EmailTemplates/EmailTemplates.pt-br.resx b/Fin.Application/Resources/EmailTemplates/EmailTemplates.pt-br.resx index 3eaaf8d..c2d824e 100644 --- a/Fin.Application/Resources/EmailTemplates/EmailTemplates.pt-br.resx +++ b/Fin.Application/Resources/EmailTemplates/EmailTemplates.pt-br.resx @@ -155,14 +155,339 @@ </html> - {{appName}} - Password Reset -We received a request to reset your password. -To create a new password, please copy and paste the link below into your browser: -{{resetLink}} -This link expires in {{linkLifeTime}} hours. -If you didn't request this, please ignore this email. + {{appName}} - Redefinição de Senha + +Recebemos uma solicitação para redefinir sua senha. Para criar uma nova senha, por favor, copie e cole o link abaixo no seu navegador: {{resetLink}} Este link expira em {{linkLifeTime}} horas. Caso você não tenha feito essa solicitação, por favor, ignore este e-mail. {{appName}} - Redefina Sua Senha + + {{appName}} - Confirme Seu Email + +Para completar seu registro, por favor utilize o código de confirmação abaixo: + +Código de Confirmação: {{confirmationCode}} + +Como usar: +1. Retorne ao aplicativo ou site. +2. Digite o código acima para ativar sua conta. + +Importante: Este código expira em 10 minutos. Se você não solicitou este código, por favor ignore este email. + +Se você não conseguir confirmar seu email, por favor entre em contato com nossa equipe de suporte. + + + <!DOCTYPE html> +<html lang='pt-BR'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Confirme Seu Email</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + .confirmation-code { + display: inline-block; + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + color: white; + padding: 20px 40px; + border-radius: 8px; + font-size: 32px; + font-weight: bold; + letter-spacing: 4px; + margin: 20px 0; + font-family: 'Courier New', monospace; + box-shadow: 0 4px 15px rgba(248, 123, 7, 0.3); + } + + .code-info { + background: #f8f9fa; + padding: 20px; + border-radius: 6px; + margin: 20px 0; + border-left: 4px solid #f87b07; + } + + .code-info p { + margin: 0; + color: #6c757d; + font-size: 14px; + text-align: left; + } + + .security-note { + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 6px; + padding: 15px; + margin: 20px 0; + } + + .security-note p { + margin: 0; + color: #856404; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='Logo {{appName}}'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Confirme Seu Email</h2> + + <p class='message'> + Para completar seu registro, por favor utilize o código de confirmação abaixo: + </p> + + <div class='confirmation-code'> + {{confirmationCode}} + </div> + + <div class='code-info'> + <p><strong>Como usar:</strong></p> + <p>1. Retorne ao aplicativo ou site</p> + <p>2. Digite o código de 6 dígitos no campo solicitado</p> + <p>3. Clique em "Confirmar" para ativar sua conta</p> + </div> + + <div class='security-note'> + <p><strong>Importante:</strong> Este código expira em 10 minutos por motivos de segurança. Se você não solicitou este código, por favor ignore este email.</p> + </div> + </div> + + <div class='footer'> + <p>Se você não conseguir confirmar seu email, por favor entre em contato com nossa equipe de suporte.</p> + <p>Este é um email automático, por favor não responda esta mensagem.</p> + </div> + </div> +</body> +</html> + + + {{appName}} - Confirmação de E-mail + + + {{appName}} - Solicitação de deleção abortada + + + {{appName}} - Solicitação de deleção abortada + +Sua solicitação de deleção do {{appName}} foi abortada. + +Sua conta não será mais deletada e continua ativa. + + + <!DOCTYPE html> +<html lang='pt-BR'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Solicitação de deleção abortada</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + /* Estilo de sucesso/confirmação positiva */ + .success-box { + background: #d4edda; + border-left: 4px solid #28a745; + padding: 20px; + border-radius: 6px; + margin: 20px 0; + text-align: left; + } + + .success-box h3 { + margin: 0 0 10px 0; + color: #155724; + font-size: 18px; + } + + .success-box p { + margin: 0; + color: #155724; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='{{appName}} logo'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Solicitação de deleção abortada</h2> + + <p class='message'> + Recebemos sua confirmação para cancelar o processo de exclusão. + </p> + + <div class='success-box'> + <h3>Conta Segura</h3> + <p>Sua solicitação de deleção do {{appName}} foi abortada e sua conta <strong>não será mais deletada</strong>.</p> + </div> + + <p class='message'> + Você pode continuar utilizando nossos serviços normalmente. + </p> + </div> + + <div class='footer'> + <p>Se você não realizou esta ação, entre em contato conosco.</p> + </div> + </div> +</body> +</html> + \ No newline at end of file diff --git a/Fin.Application/Resources/EmailTemplates/EmailTemplates.resx b/Fin.Application/Resources/EmailTemplates/EmailTemplates.resx index fe69b3f..3ce6fa0 100644 --- a/Fin.Application/Resources/EmailTemplates/EmailTemplates.resx +++ b/Fin.Application/Resources/EmailTemplates/EmailTemplates.resx @@ -172,4 +172,328 @@ If you didn't request this, please ignore this email. </body> </html> + + <!DOCTYPE html> +<html lang='en'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Confirm Your Email</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + .confirmation-code { + display: inline-block; + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + color: white; + padding: 20px 40px; + border-radius: 8px; + font-size: 32px; + font-weight: bold; + letter-spacing: 4px; + margin: 20px 0; + font-family: 'Courier New', monospace; + box-shadow: 0 4px 15px rgba(248, 123, 7, 0.3); + } + + .code-info { + background: #f8f9fa; + padding: 20px; + border-radius: 6px; + margin: 20px 0; + border-left: 4px solid #f87b07; + } + + .code-info p { + margin: 0; + color: #6c757d; + font-size: 14px; + text-align: left; + } + + .security-note { + background: #fff3cd; + border: 1px solid #ffeaa7; + border-radius: 6px; + padding: 15px; + margin: 20px 0; + } + + .security-note p { + margin: 0; + color: #856404; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='{{appName}} logo'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Confirm Your Email</h2> + + <p class='message'> + To complete your registration, please use the confirmation code below: + </p> + + <div class='confirmation-code'> + {{confirmationCode}} + </div> + + <div class='code-info'> + <p><strong>How to use:</strong></p> + <p>1. Return to the app or website</p> + <p>2. Enter the 6-digit code in the requested field</p> + <p>3. Click ""Confirm"" to activate your account</p> + </div> + + <div class='security-note'> + <p><strong>Important:</strong> This code expires in 10 minutes for security reasons. If you didn't request this code, please ignore this email.</p> + </div> + </div> + + <div class='footer'> + <p>If you're unable to confirm your email, please contact our support team.</p> + <p>This is an automated email, please do not reply to this message.</p> + </div> + </div> +</body> +</html> + + + {{appName}} - Confirm Your Email +To complete your registration, please use the confirmation code below: +Confirmation Code: {{confirmationCode}} +How to use: +1. Return to the app or website. +2. Enter the code above to activate your account. +Important: This code expires in 10 minutes. If you didn't request this code, please ignore this email. + +If you're unable to confirm your email, please contact our support team. + + + {{appName}} - Email Confirmation + + + <!DOCTYPE html> +<html lang='en'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Deletion request aborted</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + /* Success box style */ + .success-box { + background: #d4edda; + border-left: 4px solid #28a745; + padding: 20px; + border-radius: 6px; + margin: 20px 0; + text-align: left; + } + + .success-box h3 { + margin: 0 0 10px 0; + color: #155724; + font-size: 18px; + } + + .success-box p { + margin: 0; + color: #155724; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='{{appName}} logo'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Deletion request aborted</h2> + + <p class='message'> + We received your confirmation to cancel the deletion process. + </p> + + <div class='success-box'> + <h3>Account Safe</h3> + <p>Your {{appName}} deletion request has been aborted and your account <strong>will no longer be deleted</strong>.</p> + </div> + + <p class='message'> + You can continue using our services normally. + </p> + </div> + + <div class='footer'> + <p>If you did not perform this action, please contact us.</p> + </div> + </div> +</body> +</html> + + + {{appName}} - Deletion request aborted + +Your {{appName}} deletion request has been aborted. + +Your account will no longer be deleted and remains active. + + + {{appName}} - Deletion request aborted + \ No newline at end of file diff --git a/Fin.Application/Users/Services/UserCreateService.cs b/Fin.Application/Users/Services/UserCreateService.cs index 96d87df..9f802c9 100644 --- a/Fin.Application/Users/Services/UserCreateService.cs +++ b/Fin.Application/Users/Services/UserCreateService.cs @@ -1,4 +1,5 @@ -using Fin.Application.Globals.Dtos; +using Fin.Application.Emails; +using Fin.Application.Globals.Dtos; using Fin.Application.Globals.Services; using Fin.Application.Users.Dtos; using Fin.Application.Users.Enums; @@ -50,6 +51,7 @@ public class UserCreateService : IUserCreateService, IAutoTransient private readonly IConfirmationCodeGenerator _codeGenerator; private readonly IUnitOfWork _unitOfWork; private readonly IConfiguration _configuration; + private readonly IEmailTemplateService _emailTemplateService; private readonly CryptoHelper _cryptoHelper; @@ -63,7 +65,10 @@ public UserCreateService( IEmailSenderService emailSender, IConfirmationCodeGenerator codeGenerator, IRepository notificationSettingsRepository, - IRepository userRememberUseSettingRepository, IUnitOfWork unitOfWork, IRepository walletRepository) + IRepository userRememberUseSettingRepository, + IUnitOfWork unitOfWork, + IRepository walletRepository, + IEmailTemplateService emailTemplateService) { _credentialRepository = credentialRepository; _dateTimeProvider = dateTimeProvider; @@ -75,6 +80,7 @@ public UserCreateService( _userRememberUseSettingRepository = userRememberUseSettingRepository; _unitOfWork = unitOfWork; _walletRepository = walletRepository; + _emailTemplateService = emailTemplateService; _tenantRepository = tenantRepository; _userRepository = userRepository; @@ -285,17 +291,14 @@ private async Task SendConfirmationCode(string email, string confirmationCode) var frontUrl = _configuration.GetSection(AppConstants.FrontUrlConfigKey).Get(); var logoIconUrl = $"{frontUrl}/icons/fin.png"; - var htmlBody = CreateUserTemplates.SendConfirmationCodeTemplate - .Replace("{{appName}}", AppConstants.AppName) - .Replace("{{logoIconUrl}}", logoIconUrl) - .Replace("{{confirmationCode}}", confirmationCode); - - var plainBody = CreateUserTemplates.SendConfirmationCodePlainTemplate - .Replace("{{appName}}", AppConstants.AppName) - .Replace("{{confirmationCode}}", confirmationCode); - - var subject = CreateUserTemplates.SendConfirmationCodeSubject - .Replace("{{appName}}", AppConstants.AppName); + var properties = new Dictionary(); + properties.Add("appName", AppConstants.AppName); + properties.Add("logoIconUrl", logoIconUrl); + properties.Add("confirmationCode", confirmationCode); + + var htmlBody = _emailTemplateService.Get("CreateUser_ConfirmarionCode_HTML", properties); + var plainBody = _emailTemplateService.Get("CreateUser_ConfirmarionCode_Plain", properties); + var subject = _emailTemplateService.Get("CreateUser_ConfirmarionCode_Subject", properties); await _emailSender.SendEmailAsync(new SendEmailDto { diff --git a/Fin.Application/Users/Services/UserDeleteService.cs b/Fin.Application/Users/Services/UserDeleteService.cs index b7ae556..a2f7ba1 100644 --- a/Fin.Application/Users/Services/UserDeleteService.cs +++ b/Fin.Application/Users/Services/UserDeleteService.cs @@ -1,4 +1,5 @@ using System.Security; +using Fin.Application.Emails; using Fin.Application.Users.Utils; using Fin.Domain.Global; using Fin.Domain.Global.Classes; @@ -187,26 +188,10 @@ private async Task DeleteUser(Guid userId, CancellationToken cancellationToken = private async Task SendAbortDeleteEmailAsync(CancellationToken cancellationToken, UserDeleteRequest deleteRequest) { var userEmail = _cryptoHelper.Decrypt(deleteRequest.User.Credential.EncryptedEmail); - - var frontUrl = configuration.GetSection(AppConstants.FrontUrlConfigKey).Get(); - var logoIconUrl = $"{frontUrl}/icons/fin.png"; - - var htmlBody = AbortDeleteUserTemplates.AbortDeletionTemplate - .Replace("{{appName}}", AppConstants.AppName) - .Replace("{{logoIconUrl}}", logoIconUrl); - - var plainBody = AbortDeleteUserTemplates.AbortDeletionPlainTemplate - .Replace("{{appName}}", AppConstants.AppName); - - var subject = AbortDeleteUserTemplates.AbortDeletionSubject - .Replace("{{appName}}", AppConstants.AppName); - return await emailSender.SendEmailAsync(new SendEmailDto { ToEmail = userEmail, - Subject = subject, - HtmlBody = htmlBody, - PlainBody = plainBody + BaseTemplatesName = "DeleteUser_AbortDelete_" }, cancellationToken); } diff --git a/Fin.Application/Users/Utils/AbortDeleteUserTemplates.cs b/Fin.Application/Users/Utils/AbortDeleteUserTemplates.cs deleted file mode 100644 index 755e3ff..0000000 --- a/Fin.Application/Users/Utils/AbortDeleteUserTemplates.cs +++ /dev/null @@ -1,151 +0,0 @@ -namespace Fin.Application.Users.Utils; - -public static class AbortDeleteUserTemplates -{ - public const string AbortDeletionSubject = "{{appName}} - Solicitação de deleção abortada"; - - public const string AbortDeletionPlainTemplate = @" -{{appName}} - Solicitação de deleção abortada - -Sua solicitação de deleção do {{appName}} foi abortada. - -Sua conta não será mais deletada e continua ativa. -"; - - public const string AbortDeletionTemplate = @" - - - - - - Solicitação de deleção abortada - - - -
-
-
- {{appName}} logo -
-

{{appName}}

-
- -
-

Solicitação de deleção abortada

- -

- Recebemos sua confirmação para cancelar o processo de exclusão. -

- -
-

Conta Segura

-

Sua solicitação de deleção do {{appName}} foi abortada e sua conta não será mais deletada.

-
- -

- Você pode continuar utilizando nossos serviços normalmente. -

-
- - -
- - -"; -} \ No newline at end of file diff --git a/Fin.Application/Users/Utils/CreateUserTemplates.cs b/Fin.Application/Users/Utils/CreateUserTemplates.cs deleted file mode 100644 index 6af6eec..0000000 --- a/Fin.Application/Users/Utils/CreateUserTemplates.cs +++ /dev/null @@ -1,183 +0,0 @@ -namespace Fin.Application.Users.Utils; - -public static class CreateUserTemplates -{ - public const string SendConfirmationCodeSubject = "{{appName}} - Email Confirmation"; - - public const string SendConfirmationCodePlainTemplate = @" -{{appName}} - Confirm Your Email -To complete your registration, please use the confirmation code below: -Confirmation Code: {{confirmationCode}} -How to use: -1. Return to the app or website. -2. Enter the code above to activate your account. -Important: This code expires in 10 minutes. If you didn't request this code, please ignore this email. - -If you're unable to confirm your email, please contact our support team. -"; - - public const string SendConfirmationCodeTemplate = @" - - - - - - Confirm Your Email - - - -
-
-
- {{appName}} logo -
-

{{appName}}

-
- -
-

Confirm Your Email

- -

- To complete your registration, please use the confirmation code below: -

- -
- {{confirmationCode}} -
- -
-

How to use:

-

1. Return to the app or website

-

2. Enter the 6-digit code in the requested field

-

3. Click ""Confirm"" to activate your account

-
- -
-

Important: This code expires in 10 minutes for security reasons. If you didn't request this code, please ignore this email.

-
-
- - -
- - -"; -} \ No newline at end of file diff --git a/Fin.Infrastructure/EmailSenders/Dto/SendEmailDto.cs b/Fin.Infrastructure/EmailSenders/Dto/SendEmailDto.cs index ebcfefa..981c5a0 100644 --- a/Fin.Infrastructure/EmailSenders/Dto/SendEmailDto.cs +++ b/Fin.Infrastructure/EmailSenders/Dto/SendEmailDto.cs @@ -7,4 +7,29 @@ public class SendEmailDto public string Subject { get; set; } public string PlainBody { get; set; } public string HtmlBody { get; set; } + + + /// + /// Gets or sets the base prefix identifier used to automatically locate email template resources. + /// + /// + /// The system uses this base value to resolve specific template keys by appending standard suffixes. + ///
+ /// Example: If "CreateUser_ConfirmationCode_" is provided, the system will automatically look for: + /// + /// CreateUser_ConfirmationCode_Subject + /// CreateUser_ConfirmationCode_HTML + /// CreateUser_ConfirmationCode_Plain + /// + ///
+ public string BaseTemplatesName { get; set; } + + /// + /// Gets or sets the dictionary containing dynamic data for template placeholder replacement. + /// + /// + /// The dictionary keys must match the placeholders defined in the template (e.g., {{UserName}}), + /// and the values represent the content to be rendered. + /// + public Dictionary TemplateProperties { get; set; } } \ No newline at end of file diff --git a/Fin.Infrastructure/EmailSenders/EmailSenderService.cs b/Fin.Infrastructure/EmailSenders/MailKit/MailKitClient.cs similarity index 55% rename from Fin.Infrastructure/EmailSenders/EmailSenderService.cs rename to Fin.Infrastructure/EmailSenders/MailKit/MailKitClient.cs index aaada33..4926ca0 100644 --- a/Fin.Infrastructure/EmailSenders/EmailSenderService.cs +++ b/Fin.Infrastructure/EmailSenders/MailKit/MailKitClient.cs @@ -1,43 +1,22 @@ -using Fin.Infrastructure.AutoServices.Interfaces; -using Fin.Infrastructure.EmailSenders.Constants; using Fin.Infrastructure.EmailSenders.Dto; -using Fin.Infrastructure.EmailSenders.MailSender; using MailKit.Net.Smtp; using MailKit.Security; using Microsoft.Extensions.Configuration; using MimeKit; -namespace Fin.Infrastructure.EmailSenders; +namespace Fin.Infrastructure.EmailSenders.MailKit; -public interface IEmailSenderService +public interface IMailKitClient { - public Task SendEmailAsync(SendEmailDto dto, CancellationToken cancellationToken = default); + public Task SendEmailAsync(SendEmailDto dto, CancellationToken cancellationToken); } -public class EmailSenderService( - IConfiguration configuration, - IMailSenderClient mailSenderClient - ) : IEmailSenderService, IAutoTransient +public class MailKitClient(IConfiguration configuration): IMailKitClient { private const string EmailConfigKey = "ApiSettings:EmailSender:EmailAddress"; private const string PasswordConfigKey = "ApiSettings:EmailSender:Password"; - - public async Task SendEmailAsync(SendEmailDto dto, CancellationToken cancellationToken = default) - { - return GetMailService() switch - { - MailServicesConst.MailSender => await mailSenderClient.SendEmailAsync(dto, cancellationToken), - _ => await SendEmailWithMailKit(dto, cancellationToken) - }; - } - private string GetMailService() - { - var mailService = configuration.GetSection(MailServicesConst.MailServiceConfigurationKey).Value; - return mailService ?? ""; - } - - private async Task SendEmailWithMailKit(SendEmailDto dto, CancellationToken cancellationToken) + public async Task SendEmailAsync(SendEmailDto dto, CancellationToken cancellationToken) { var emailAddress = configuration.GetSection(EmailConfigKey).Value ?? ""; var emailPassword = configuration.GetSection(PasswordConfigKey).Value ?? ""; diff --git a/Fin.Infrastructure/EmailSenders/MailKit/MailKitClientExtension.cs b/Fin.Infrastructure/EmailSenders/MailKit/MailKitClientExtension.cs new file mode 100644 index 0000000..3738008 --- /dev/null +++ b/Fin.Infrastructure/EmailSenders/MailKit/MailKitClientExtension.cs @@ -0,0 +1,14 @@ +using Fin.Infrastructure.EmailSenders.MailSender; +using Microsoft.Extensions.DependencyInjection; + +namespace Fin.Infrastructure.EmailSenders.MailKit; + +public static class MailKitClientExtension +{ + public static IServiceCollection AddMailKitClient(this IServiceCollection services) + { + services.AddHttpClient(); + + return services; + } +} \ No newline at end of file diff --git a/Fin.Infrastructure/Extensions/AddInfrastructureExtension.cs b/Fin.Infrastructure/Extensions/AddInfrastructureExtension.cs index 5580daa..e95fb8c 100644 --- a/Fin.Infrastructure/Extensions/AddInfrastructureExtension.cs +++ b/Fin.Infrastructure/Extensions/AddInfrastructureExtension.cs @@ -4,6 +4,7 @@ using Fin.Infrastructure.BackgroundJobs; using Fin.Infrastructure.Database.Extensions; using Fin.Infrastructure.EmailSenders.MailSender; +using Fin.Infrastructure.EmailSenders.MailKit; using Fin.Infrastructure.Firebases; using Fin.Infrastructure.Notifications.Hubs; using Fin.Infrastructure.Redis; @@ -29,7 +30,8 @@ public static IServiceCollection AddInfrastructure(this IServiceCollection servi .AddFirebase(configuration) .AddSeeders() .AddNotifications() - .AddMailSenderClient(); + .AddMailSenderClient() + .AddMailKitClient(); return services; } diff --git a/Fin.Test/Authentications/AuthenticationServiceTest.cs b/Fin.Test/Authentications/AuthenticationServiceTest.cs index d901585..12ff5b9 100644 --- a/Fin.Test/Authentications/AuthenticationServiceTest.cs +++ b/Fin.Test/Authentications/AuthenticationServiceTest.cs @@ -1,6 +1,7 @@ using Fin.Application.Authentications.Dtos; using Fin.Application.Authentications.Enums; using Fin.Application.Authentications.Services; +using Fin.Application.Emails; using Fin.Application.Globals.Dtos; using Fin.Application.Users.Services; using Fin.Domain.Global; diff --git a/Fin.Test/EmailSenders/EmailSenderServiceTest.cs b/Fin.Test/EmailSenders/EmailSenderServiceTest.cs index ef5ae27..9be4aec 100644 --- a/Fin.Test/EmailSenders/EmailSenderServiceTest.cs +++ b/Fin.Test/EmailSenders/EmailSenderServiceTest.cs @@ -1,3 +1,4 @@ +using Fin.Application.Emails; using Fin.Infrastructure.EmailSenders; using Fin.Infrastructure.EmailSenders.Constants; using Fin.Infrastructure.EmailSenders.Dto; diff --git a/Fin.Test/Notifications/Services/NotificationDeliveryServiceTest.cs b/Fin.Test/Notifications/Services/NotificationDeliveryServiceTest.cs index 0b9bcfb..beedd42 100644 --- a/Fin.Test/Notifications/Services/NotificationDeliveryServiceTest.cs +++ b/Fin.Test/Notifications/Services/NotificationDeliveryServiceTest.cs @@ -1,4 +1,5 @@ -using Fin.Application.Notifications.Services.DeliveryServices; +using Fin.Application.Emails; +using Fin.Application.Notifications.Services.DeliveryServices; using Fin.Domain.Global; using Fin.Domain.Notifications.Dtos; using Fin.Domain.Notifications.Entities; diff --git a/Fin.Test/Users/UserCreateServiceTest.cs b/Fin.Test/Users/UserCreateServiceTest.cs index 7293a8e..183dc35 100644 --- a/Fin.Test/Users/UserCreateServiceTest.cs +++ b/Fin.Test/Users/UserCreateServiceTest.cs @@ -1,4 +1,5 @@ -using Fin.Application.Globals.Services; +using Fin.Application.Emails; +using Fin.Application.Globals.Services; using Fin.Application.Users.Dtos; using Fin.Application.Users.Enums; using Fin.Application.Users.Services; diff --git a/Fin.Test/Users/UserDeleteServiceTest.cs b/Fin.Test/Users/UserDeleteServiceTest.cs index 8511a42..9b6c9ad 100644 --- a/Fin.Test/Users/UserDeleteServiceTest.cs +++ b/Fin.Test/Users/UserDeleteServiceTest.cs @@ -1,4 +1,5 @@ using System.Security; +using Fin.Application.Emails; using Fin.Application.Users.Services; using Fin.Domain.Global; using Fin.Domain.Global.Classes; From b61a16f6741fdca7508207418320d636c269f76f Mon Sep 17 00:00:00 2001 From: Rafael Kaua Dos Santos Chicovis Date: Tue, 2 Dec 2025 13:45:07 -0300 Subject: [PATCH 06/14] FIN-77 changed all templates to resources --- Fin-Backend.sln.DotSettings.user | 8 +- .../Services/AuthenticationService.cs | 20 +- .../EmailTemplates/EmailTemplates.es-ES.resx | 300 ++++++++++++++++++ .../EmailTemplates/EmailTemplates.pt-br.resx | 296 +++++++++++++++++ .../EmailTemplates/EmailTemplates.resx | 300 ++++++++++++++++++ .../Users/Services/UserCreateService.cs | 26 +- .../Users/Services/UserDeleteService.cs | 58 +--- .../Users/Utils/AccountDeletedTemplates.cs | 153 --------- .../Users/Utils/DeleteUserTemplates.cs | 153 --------- 9 files changed, 922 insertions(+), 392 deletions(-) delete mode 100644 Fin.Application/Users/Utils/AccountDeletedTemplates.cs delete mode 100644 Fin.Application/Users/Utils/DeleteUserTemplates.cs diff --git a/Fin-Backend.sln.DotSettings.user b/Fin-Backend.sln.DotSettings.user index 0f1dcb6..f5160e7 100644 --- a/Fin-Backend.sln.DotSettings.user +++ b/Fin-Backend.sln.DotSettings.user @@ -13,11 +13,11 @@ ForceIncluded ForceIncluded ForceIncluded - <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> + <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="MailSenderClientTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Project Location="/home/rafaelchicovis/git/fin-backend/Fin.Test" Presentation="&lt;Fin.Test&gt;" /> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="MailSenderClientTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Project Location="\home\rafaelchicovis\git\fin-backend\Fin.Test" Presentation="&lt;Fin.Test&gt;" /> </SessionState> True False diff --git a/Fin.Application/Authentications/Services/AuthenticationService.cs b/Fin.Application/Authentications/Services/AuthenticationService.cs index 786626a..54e7766 100644 --- a/Fin.Application/Authentications/Services/AuthenticationService.cs +++ b/Fin.Application/Authentications/Services/AuthenticationService.cs @@ -1,11 +1,9 @@ using Fin.Application.Authentications.Dtos; using Fin.Application.Authentications.Enums; -using Fin.Application.Authentications.Utils; using Fin.Application.Emails; using Fin.Application.Globals.Dtos; using Fin.Application.Users.Services; using Fin.Domain.Global; -using Fin.Domain.Tenants.Entities; using Fin.Domain.Users.Dtos; using Fin.Domain.Users.Entities; using Fin.Infrastructure.Authentications; @@ -15,7 +13,6 @@ using Fin.Infrastructure.AutoServices.Interfaces; using Fin.Infrastructure.Constants; using Fin.Infrastructure.Database.Repositories; -using Fin.Infrastructure.EmailSenders; using Fin.Infrastructure.EmailSenders.Dto; using Fin.Infrastructure.Redis; using Microsoft.EntityFrameworkCore; @@ -43,7 +40,6 @@ public class AuthenticationService : IAuthenticationService, IAutoTransient private readonly IUserCreateService _userCreateService; private readonly IAuthenticationTokenService _tokenService; private readonly IConfiguration _configuration; - private readonly IEmailTemplateService _emailTemplateService; private readonly CryptoHelper _cryptoHelper; @@ -53,8 +49,7 @@ public AuthenticationService( IEmailSenderService emailSender, IConfiguration configuration, IAuthenticationTokenService tokenService, - IUserCreateService userCreateService, - IEmailTemplateService emailTemplateService) + IUserCreateService userCreateService) { _credentialRepository = credentialRepository; _cache = cache; @@ -62,7 +57,7 @@ public AuthenticationService( _configuration = configuration; _tokenService = tokenService; _userCreateService = userCreateService; - _emailTemplateService = emailTemplateService; + var encryptKey = configuration.GetSection(AuthenticationConstants.EncryptKeyConfigKey).Value ?? ""; var encryptIv = configuration.GetSection(AuthenticationConstants.EncryptIvConfigKey).Value ?? ""; @@ -99,17 +94,12 @@ public async Task SendResetPasswordEmail(SendResetPasswordEmailInput input) parameters.Add("linkLifeTime", tokenLifeTimeInHours.ToString()); parameters.Add("resetLink", resetLink); parameters.Add("logoIconUrl", logoIconUrl); - - var subject = _emailTemplateService.Get("ResetPassword_Subject", parameters); - var plainBody = _emailTemplateService.Get("ResetPassword_Plain", parameters); - var htmlBody = _emailTemplateService.Get("ResetPassword_HTML", parameters); - + await _emailSender.SendEmailAsync(new SendEmailDto { - Subject = subject, + BaseTemplatesName = "ResetPassword_", + TemplateProperties = parameters, ToEmail = input.Email, - PlainBody = plainBody, - HtmlBody = htmlBody, ToName = credential.User.DisplayName }); } diff --git a/Fin.Application/Resources/EmailTemplates/EmailTemplates.es-ES.resx b/Fin.Application/Resources/EmailTemplates/EmailTemplates.es-ES.resx index e3d5dcd..7485b01 100644 --- a/Fin.Application/Resources/EmailTemplates/EmailTemplates.es-ES.resx +++ b/Fin.Application/Resources/EmailTemplates/EmailTemplates.es-ES.resx @@ -493,4 +493,304 @@ Su cuenta ya no será eliminada y permanece activa. {{appName}} - Solicitud de eliminación cancelada + + <!DOCTYPE html> +<html lang='es'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Solicitud de Eliminación</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + .alert-box { + background: #fff3cd; + border-left: 4px solid #f87b07; + padding: 20px; + border-radius: 6px; + margin: 20px 0; + text-align: left; + } + + .alert-box h3 { + margin: 0 0 10px 0; + color: #856404; + font-size: 18px; + } + + .alert-box p { + margin: 0; + color: #856404; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='{{appName}} logo'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Solicitud de Eliminación</h2> + + <p class='message'> + Hemos recibido su solicitud de eliminación de cuenta. + </p> + + <div class='alert-box'> + <h3>Cuenta Desactivada</h3> + <p>Su cuenta está programada para ser <strong>eliminada permanentemente en 30 días</strong>.</p> + </div> + + <p class='message'> + Si cambia de opinión o realizó esta solicitud por error, póngase en contacto con nuestro equipo de soporte inmediatamente para cancelar la eliminación. + </p> + </div> + + <div class='footer'> + <p>Este es un correo electrónico automático de seguridad.</p> + </div> + </div> +</body> +</html> + + + + {{appName}} - Solicitud de Eliminación + +Hemos recibido su solicitud de eliminación de cuenta. + +Su cuenta ha sido desactivada y será eliminada en 30 días. + +Si cambia de opinión, póngase en contacto con nuestro equipo de soporte para cancelar la eliminación. + + + + {{appName}} - Solicitud de Eliminación + + + + <!DOCTYPE html> +<html lang='es'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Cuenta Eliminada</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + .deletion-notice { + background: #f8d7da; + border-left: 4px solid #dc3545; + padding: 20px; + border-radius: 6px; + margin: 20px 0; + text-align: left; + } + + .deletion-notice h3 { + margin: 0 0 10px 0; + color: #721c24; + font-size: 18px; + } + + .deletion-notice p { + margin: 0; + color: #721c24; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='{{appName}} logo'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Cuenta Eliminada</h2> + + <p class='message'> + Le informamos que el proceso de eliminación ha sido completado. + </p> + + <div class='deletion-notice'> + <h3>Acceso Eliminado</h3> + <p>Su cuenta de {{appName}} ha sido eliminada. Ya no podrá acceder a sus datos y estos han sido removidos de la plataforma.</p> + </div> + + <p class='message'> + Le agradecemos por el tiempo que estuvo con nosotros. + </p> + </div> + + <div class='footer'> + <p>Esperamos verlo nuevamente en el futuro.</p> + </div> + </div> +</body> +</html> + + + + {{appName}} - Cuenta Eliminada + +Su cuenta de {{appName}} ha sido eliminada. + +Ya no podrá acceder a sus datos y estos han sido eliminados de la plataforma. + +Le agradecemos por el tiempo que estuvo con nosotros. + + + + {{appName}} - Cuenta Eliminada + + \ No newline at end of file diff --git a/Fin.Application/Resources/EmailTemplates/EmailTemplates.pt-br.resx b/Fin.Application/Resources/EmailTemplates/EmailTemplates.pt-br.resx index c2d824e..a18be47 100644 --- a/Fin.Application/Resources/EmailTemplates/EmailTemplates.pt-br.resx +++ b/Fin.Application/Resources/EmailTemplates/EmailTemplates.pt-br.resx @@ -488,6 +488,302 @@ Sua conta não será mais deletada e continua ativa. </div> </div> </body> +</html> + + + {{appName}} - Solicitação de Deleção + + + {{appName}} - Solicitação de Deleção + +Recebemos sua solicitação de deleção de conta. + +Sua conta foi inativada e será deletada em 30 dias. + +Caso você se arrependa, entre em contato com nosso suporte para abortar a deleção. + + + <!DOCTYPE html> +<html lang='pt-BR'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Solicitação de Deleção</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + /* Estilo de alerta para a inativação */ + .alert-box { + background: #fff3cd; + border-left: 4px solid #f87b07; + padding: 20px; + border-radius: 6px; + margin: 20px 0; + text-align: left; + } + + .alert-box h3 { + margin: 0 0 10px 0; + color: #856404; + font-size: 18px; + } + + .alert-box p { + margin: 0; + color: #856404; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='{{appName}} logo'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Solicitação de Deleção</h2> + + <p class='message'> + Recebemos sua solicitação de deleção de conta. + </p> + + <div class='alert-box'> + <h3>Conta Inativada</h3> + <p>Sua conta está programada para ser <strong>excluída permanentemente em 30 dias</strong>.</p> + </div> + + <p class='message'> + Caso você se arrependa ou tenha feito isso por engano, entre em contato com nosso suporte imediatamente para abortar a deleção. + </p> + </div> + + <div class='footer'> + <p>Este é um email automático de segurança.</p> + </div> + </div> +</body> +</html> + + + {{appName}} - Conta deletada + + + {{appName}} - Conta deletada + +Sua conta no {{appName}} foi deletada. + +Agora você não poderá mais acessar seus dados e eles foram removidos da plataforma. + +Agradecemos pelo tempo que passou conosco. + + + <!DOCTYPE html> +<html lang='pt-BR'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Conta deletada</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + /* Estilo de 'Perigo/Remoção' para enfatizar que acabou */ + .deletion-notice { + background: #f8d7da; + border-left: 4px solid #dc3545; + padding: 20px; + border-radius: 6px; + margin: 20px 0; + text-align: left; + } + + .deletion-notice h3 { + margin: 0 0 10px 0; + color: #721c24; + font-size: 18px; + } + + .deletion-notice p { + margin: 0; + color: #721c24; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='{{appName}} logo'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Conta Deletada</h2> + + <p class='message'> + Informamos que o processo de exclusão foi concluído. + </p> + + <div class='deletion-notice'> + <h3>Acesso Removido</h3> + <p>Sua conta no {{appName}} foi deletada. Agora você não poderá mais acessar seus dados e eles foram removidos da plataforma.</p> + </div> + + <p class='message'> + Agradecemos pelo tempo em que esteve conosco. + </p> + </div> + + <div class='footer'> + <p>Esperamos vê-lo novamente no futuro.</p> + </div> + </div> +</body> </html> \ No newline at end of file diff --git a/Fin.Application/Resources/EmailTemplates/EmailTemplates.resx b/Fin.Application/Resources/EmailTemplates/EmailTemplates.resx index 3ce6fa0..3138f7c 100644 --- a/Fin.Application/Resources/EmailTemplates/EmailTemplates.resx +++ b/Fin.Application/Resources/EmailTemplates/EmailTemplates.resx @@ -496,4 +496,304 @@ Your account will no longer be deleted and remains active. {{appName}} - Deletion request aborted + + <!DOCTYPE html> +<html lang='en'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Deletion Request</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + .alert-box { + background: #fff3cd; + border-left: 4px solid #f87b07; + padding: 20px; + border-radius: 6px; + margin: 20px 0; + text-align: left; + } + + .alert-box h3 { + margin: 0 0 10px 0; + color: #856404; + font-size: 18px; + } + + .alert-box p { + margin: 0; + color: #856404; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='{{appName}} logo'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Deletion Request</h2> + + <p class='message'> + We have received your account deletion request. + </p> + + <div class='alert-box'> + <h3>Account Deactivated</h3> + <p>Your account is scheduled to be <strong>permanently deleted in 30 days</strong>.</p> + </div> + + <p class='message'> + If you change your mind or submitted this request by mistake, please contact our support team immediately to cancel the deletion. + </p> + </div> + + <div class='footer'> + <p>This is an automated security email.</p> + </div> + </div> +</body> +</html> + + + + {{appName}} - Deletion Request + +We have received your account deletion request. + +Your account has been deactivated and will be deleted in 30 days. + +If you change your mind, please contact our support team to cancel the deletion. + + + + {{appName}} - Deletion Request + + + + <!DOCTYPE html> +<html lang='en'> +<head> + <meta charset='UTF-8'> + <meta name='viewport' content='width=device-width, initial-scale=1.0'> + <title>Account Deleted</title> + <style> + body { + font-family: Arial, sans-serif; + background-color: #f8f9fa; + color: #212529; + margin: 0; + padding: 20px; + } + + .container { + max-width: 600px; + margin: 0 auto; + background: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + + .header { + background: linear-gradient(135deg, #f87b07 0%, #fdc570 100%); + padding: 30px; + text-align: center; + border-radius: 8px 8px 0 0; + } + + .app-icon { + width: 60px; + height: 60px; + background: white; + border-radius: 8px; + display: inline-flex; + align-items: center; + justify-content: center; + margin-bottom: 15px; + } + + .app-icon img { + width: 40px; + height: 40px; + } + + .app-name { + color: white; + font-size: 24px; + font-weight: bold; + margin: 0; + } + + .content { + padding: 40px 30px; + text-align: center; + } + + .title { + font-size: 22px; + color: rgb(46, 38, 26); + margin-bottom: 20px; + } + + .message { + color: #6c757d; + margin-bottom: 30px; + line-height: 1.5; + } + + .deletion-notice { + background: #f8d7da; + border-left: 4px solid #dc3545; + padding: 20px; + border-radius: 6px; + margin: 20px 0; + text-align: left; + } + + .deletion-notice h3 { + margin: 0 0 10px 0; + color: #721c24; + font-size: 18px; + } + + .deletion-notice p { + margin: 0; + color: #721c24; + font-size: 14px; + } + + .footer { + background: rgb(46, 38, 26); + color: #fdc570; + padding: 20px 30px; + text-align: center; + border-radius: 0 0 8px 8px; + font-size: 14px; + } + </style> +</head> +<body> + <div class='container'> + <div class='header'> + <div class='app-icon'> + <img src='{{logoIconUrl}}' alt='{{appName}} logo'> + </div> + <h1 class='app-name'>{{appName}}</h1> + </div> + + <div class='content'> + <h2 class='title'>Account Deleted</h2> + + <p class='message'> + We inform you that the deletion process has been completed. + </p> + + <div class='deletion-notice'> + <h3>Access Removed</h3> + <p>Your {{appName}} account has been deleted. You will no longer be able to access your data, and it has been removed from the platform.</p> + </div> + + <p class='message'> + Thank you for the time you spent with us. + </p> + </div> + + <div class='footer'> + <p>We hope to see you again in the future.</p> + </div> + </div> +</body> +</html> + + + + {{appName}} - Account Deleted + + + + {{appName}} - Account Deleted + +Your {{appName}} account has been deleted. + +You will no longer be able to access your data, and it has been removed from the platform. + +Thank you for the time you spent with us. + + \ No newline at end of file diff --git a/Fin.Application/Users/Services/UserCreateService.cs b/Fin.Application/Users/Services/UserCreateService.cs index 9f802c9..cacbc38 100644 --- a/Fin.Application/Users/Services/UserCreateService.cs +++ b/Fin.Application/Users/Services/UserCreateService.cs @@ -3,7 +3,6 @@ using Fin.Application.Globals.Services; using Fin.Application.Users.Dtos; using Fin.Application.Users.Enums; -using Fin.Application.Users.Utils; using Fin.Domain.Global; using Fin.Domain.Notifications.Entities; using Fin.Domain.Tenants.Entities; @@ -14,10 +13,8 @@ using Fin.Domain.Wallets.Entities; using Fin.Infrastructure.Authentications.Constants; using Fin.Infrastructure.AutoServices.Interfaces; -using Fin.Infrastructure.Constants; using Fin.Infrastructure.Database.Repositories; using Fin.Infrastructure.DateTimes; -using Fin.Infrastructure.EmailSenders; using Fin.Infrastructure.EmailSenders.Dto; using Fin.Infrastructure.Redis; using Fin.Infrastructure.UnitOfWorks; @@ -50,8 +47,6 @@ public class UserCreateService : IUserCreateService, IAutoTransient private readonly IEmailSenderService _emailSender; private readonly IConfirmationCodeGenerator _codeGenerator; private readonly IUnitOfWork _unitOfWork; - private readonly IConfiguration _configuration; - private readonly IEmailTemplateService _emailTemplateService; private readonly CryptoHelper _cryptoHelper; @@ -67,12 +62,10 @@ public UserCreateService( IRepository notificationSettingsRepository, IRepository userRememberUseSettingRepository, IUnitOfWork unitOfWork, - IRepository walletRepository, - IEmailTemplateService emailTemplateService) + IRepository walletRepository) { _credentialRepository = credentialRepository; _dateTimeProvider = dateTimeProvider; - _configuration = configuration; _cache = cache; _emailSender = emailSender; _codeGenerator = codeGenerator; @@ -80,7 +73,6 @@ public UserCreateService( _userRememberUseSettingRepository = userRememberUseSettingRepository; _unitOfWork = unitOfWork; _walletRepository = walletRepository; - _emailTemplateService = emailTemplateService; _tenantRepository = tenantRepository; _userRepository = userRepository; @@ -287,25 +279,15 @@ private ValidationResultDto Get } private async Task SendConfirmationCode(string email, string confirmationCode) - { - var frontUrl = _configuration.GetSection(AppConstants.FrontUrlConfigKey).Get(); - var logoIconUrl = $"{frontUrl}/icons/fin.png"; - + { var properties = new Dictionary(); - properties.Add("appName", AppConstants.AppName); - properties.Add("logoIconUrl", logoIconUrl); properties.Add("confirmationCode", confirmationCode); - var htmlBody = _emailTemplateService.Get("CreateUser_ConfirmarionCode_HTML", properties); - var plainBody = _emailTemplateService.Get("CreateUser_ConfirmarionCode_Plain", properties); - var subject = _emailTemplateService.Get("CreateUser_ConfirmarionCode_Subject", properties); - await _emailSender.SendEmailAsync(new SendEmailDto { ToEmail = email, - Subject = subject, - HtmlBody = htmlBody, - PlainBody = plainBody + TemplateProperties = properties, + BaseTemplatesName = "CreateUser_ConfirmarionCode_", }, CancellationToken.None); } } \ No newline at end of file diff --git a/Fin.Application/Users/Services/UserDeleteService.cs b/Fin.Application/Users/Services/UserDeleteService.cs index a2f7ba1..70c4d9b 100644 --- a/Fin.Application/Users/Services/UserDeleteService.cs +++ b/Fin.Application/Users/Services/UserDeleteService.cs @@ -1,9 +1,7 @@ using System.Security; using Fin.Application.Emails; -using Fin.Application.Users.Utils; using Fin.Domain.Global; using Fin.Domain.Global.Classes; -using Fin.Domain.Notifications.Dtos; using Fin.Domain.Notifications.Entities; using Fin.Domain.Tenants.Entities; using Fin.Domain.Users.Dtos; @@ -11,14 +9,11 @@ using Fin.Infrastructure.AmbientDatas; using Fin.Infrastructure.Authentications.Constants; using Fin.Infrastructure.AutoServices.Interfaces; -using Fin.Infrastructure.Constants; using Fin.Infrastructure.Database.Extensions; using Fin.Infrastructure.Database.Repositories; using Fin.Infrastructure.DateTimes; -using Fin.Infrastructure.EmailSenders; using Fin.Infrastructure.EmailSenders.Dto; using Fin.Infrastructure.UnitOfWorks; -using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; @@ -29,7 +24,9 @@ public interface IUserDeleteService public Task RequestDeleteUser(CancellationToken cancellationToken = default); public Task EffectiveDeleteUsers(CancellationToken cancellationToken = default); public Task AbortDeleteUser(Guid userId, CancellationToken cancellationToken = default); - public Task> GetList(PagedFilteredAndSortedInput input, CancellationToken cancellationToken = default); + + public Task> GetList(PagedFilteredAndSortedInput input, + CancellationToken cancellationToken = default); } public class UserDeleteService( @@ -106,14 +103,15 @@ public async Task AbortDeleteUser(Guid userId, CancellationToken cancellat return true; } - public async Task> GetList(PagedFilteredAndSortedInput input, CancellationToken cancellationToken = default) + public async Task> GetList(PagedFilteredAndSortedInput input, + CancellationToken cancellationToken = default) { return await userDeleteRequestRepo.AsNoTracking() .Include(u => u.User) .Include(u => u.UserAborted) .ApplyFilterAndSorter(input) .Select(n => new UserDeleteRequestDto(n)) - .ToPagedResult(input, cancellationToken ); + .ToPagedResult(input, cancellationToken); } private async Task DeleteUser(Guid userId, CancellationToken cancellationToken = default) @@ -157,7 +155,6 @@ private async Task DeleteUser(Guid userId, CancellationToken cancellationToken = await using (var scope = await unitOfWork.BeginTransactionAsync(cancellationToken)) { - foreach (var notification in notifications) await notificationRepo.DeleteAsync(notification, cancellationToken); foreach (var delivery in notificationDeliveries) @@ -184,8 +181,9 @@ private async Task DeleteUser(Guid userId, CancellationToken cancellationToken = await scope.CompleteAsync(cancellationToken); } } - - private async Task SendAbortDeleteEmailAsync(CancellationToken cancellationToken, UserDeleteRequest deleteRequest) + + private async Task SendAbortDeleteEmailAsync(CancellationToken cancellationToken, + UserDeleteRequest deleteRequest) { var userEmail = _cryptoHelper.Decrypt(deleteRequest.User.Credential.EncryptedEmail); return await emailSender.SendEmailAsync(new SendEmailDto @@ -194,52 +192,22 @@ private async Task SendAbortDeleteEmailAsync(CancellationToken cancellatio BaseTemplatesName = "DeleteUser_AbortDelete_" }, cancellationToken); } - + private async Task SendDeleteAccountEmailAsync(CancellationToken cancellationToken, string userEmail) { - var frontUrl = configuration.GetSection(AppConstants.FrontUrlConfigKey).Get(); - var logoIconUrl = $"{frontUrl}/icons/fin.png"; - - var htmlBody = DeleteUserTemplates.AccountDeletionTemplate - .Replace("{{appName}}", AppConstants.AppName) - .Replace("{{logoIconUrl}}", logoIconUrl); - - var plainBody = DeleteUserTemplates.AccountDeletionPlainTemplate - .Replace("{{appName}}", AppConstants.AppName); - - var subject = DeleteUserTemplates.AccountDeletionSubject - .Replace("{{appName}}", AppConstants.AppName); - return await emailSender.SendEmailAsync(new SendEmailDto { ToEmail = userEmail, - Subject = subject, - HtmlBody = htmlBody, - PlainBody = plainBody + BaseTemplatesName = "DeleteUser_Deletion_" }, cancellationToken); } - + private async Task SendAccountDeletedEmailAsync(CancellationToken cancellationToken, string userEmail) { - var frontUrl = configuration.GetSection(AppConstants.FrontUrlConfigKey).Get(); - var logoIconUrl = $"{frontUrl}/icons/fin.png"; - - var htmlBody = AccountDeletedTemplates.AccountDeletedTemplate - .Replace("{{appName}}", AppConstants.AppName) - .Replace("{{logoIconUrl}}", logoIconUrl); - - var plainBody = AccountDeletedTemplates.AccountDeletedPlainTemplate - .Replace("{{appName}}", AppConstants.AppName); - - var subject = AccountDeletedTemplates.AccountDeletedSubject - .Replace("{{appName}}", AppConstants.AppName); - return await emailSender.SendEmailAsync(new SendEmailDto { ToEmail = userEmail, - Subject = subject, - HtmlBody = htmlBody, - PlainBody = plainBody + BaseTemplatesName = "DeleteUser_AccountDeleted_" }, cancellationToken); } } \ No newline at end of file diff --git a/Fin.Application/Users/Utils/AccountDeletedTemplates.cs b/Fin.Application/Users/Utils/AccountDeletedTemplates.cs deleted file mode 100644 index 3a3230e..0000000 --- a/Fin.Application/Users/Utils/AccountDeletedTemplates.cs +++ /dev/null @@ -1,153 +0,0 @@ -namespace Fin.Application.Users.Utils; - -public static class AccountDeletedTemplates -{ - public const string AccountDeletedSubject = "{{appName}} - Conta deletada"; - - public const string AccountDeletedPlainTemplate = @" -{{appName}} - Conta deletada - -Sua conta no {{appName}} foi deletada. - -Agora você não poderá mais acessar seus dados e eles foram removidos da plataforma. - -Agradecemos pelo tempo que passou conosco. -"; - - public const string AccountDeletedTemplate = @" - - - - - - Conta deletada - - - -
-
-
- {{appName}} logo -
-

{{appName}}

-
- -
-

Conta Deletada

- -

- Informamos que o processo de exclusão foi concluído. -

- -
-

Acesso Removido

-

Sua conta no {{appName}} foi deletada. Agora você não poderá mais acessar seus dados e eles foram removidos da plataforma.

-
- -

- Agradecemos pelo tempo em que esteve conosco. -

-
- - -
- - -"; -} \ No newline at end of file diff --git a/Fin.Application/Users/Utils/DeleteUserTemplates.cs b/Fin.Application/Users/Utils/DeleteUserTemplates.cs deleted file mode 100644 index 77acf43..0000000 --- a/Fin.Application/Users/Utils/DeleteUserTemplates.cs +++ /dev/null @@ -1,153 +0,0 @@ -namespace Fin.Application.Users.Utils; - -public static class DeleteUserTemplates -{ - public const string AccountDeletionSubject = "{{appName}} - Solicitação de Deleção"; - - public const string AccountDeletionPlainTemplate = @" -{{appName}} - Solicitação de Deleção - -Recebemos sua solicitação de deleção de conta. - -Sua conta foi inativada e será deletada em 30 dias. - -Caso você se arrependa, entre em contato com nosso suporte para abortar a deleção. -"; - - public const string AccountDeletionTemplate = @" - - - - - - Solicitação de Deleção - - - -
-
-
- {{appName}} logo -
-

{{appName}}

-
- -
-

Solicitação de Deleção

- -

- Recebemos sua solicitação de deleção de conta. -

- -
-

Conta Inativada

-

Sua conta está programada para ser excluída permanentemente em 30 dias.

-
- -

- Caso você se arrependa ou tenha feito isso por engano, entre em contato com nosso suporte imediatamente para abortar a deleção. -

-
- - -
- - -"; -} \ No newline at end of file From 77807bb326659d47242a77e88d0c3eece73da3fa Mon Sep 17 00:00:00 2001 From: RafaelKC Date: Tue, 2 Dec 2025 13:59:15 -0300 Subject: [PATCH 07/14] FIN-76 adjusting existing tests --- .../AuthenticationTokenServiceTest.cs | 15 +++++++-------- Fin.Test/EmailSenders/EmailSenderServiceTest.cs | 5 ++++- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Fin.Test/Authentications/AuthenticationTokenServiceTest.cs b/Fin.Test/Authentications/AuthenticationTokenServiceTest.cs index 1128d0a..301190d 100644 --- a/Fin.Test/Authentications/AuthenticationTokenServiceTest.cs +++ b/Fin.Test/Authentications/AuthenticationTokenServiceTest.cs @@ -58,7 +58,7 @@ public async Task Login_Success() }, now) { Id = TestUtils.Guids[0], - Tenants = [new Tenant(now)] + Tenants = [new Tenant(now, TestUtils.Strings[1], TestUtils.Strings[2])] }; var credential = UserCredentialFactory.Create(user.Id, encryptedEmail, encryptedPass, UserCredentialFactoryType.Password); @@ -110,7 +110,7 @@ public async Task Login_Fail(LoginErrorCode code, string credentialEmail, string }, now) { Id = TestUtils.Guids[0], - Tenants = [new Tenant(now)] + Tenants = [new Tenant(now, TestUtils.Strings[1], TestUtils.Strings[2])] }; var credential = UserCredentialFactory.Create(user.Id, encryptedEmail, encryptedPass, UserCredentialFactoryType.Password); credential.Id = TestUtils.Guids[1]; @@ -118,7 +118,6 @@ public async Task Login_Fail(LoginErrorCode code, string credentialEmail, string if (!activatedUser) user.ToggleActivity(); - ; for (var i = 0; i < previusesAttempts; i++) { @@ -190,7 +189,7 @@ public async Task RefreshToken_Success() Id = userId, Tenants = new List { - new(now) + new(now, TestUtils.Strings[1], TestUtils.Strings[2]) } }; await resources.UseRepository.AddAsync(user, true); @@ -240,7 +239,7 @@ public async Task RefreshToken_InactivatedUser() Id = userId, Tenants = new List { - new(now) + new(now, TestUtils.Strings[1], TestUtils.Strings[2]) } }; user.ToggleActivity(); @@ -386,7 +385,7 @@ public async Task GenerateTokenAsync() var user = new UserDto() { DisplayName = TestUtils.Strings[1], - Tenants = { new Tenant(now) }, + Tenants = { new Tenant(now, TestUtils.Strings[1], TestUtils.Strings[2]) }, Id = TestUtils.Guids[0] }; @@ -446,8 +445,8 @@ private MockResources CreateMockResources() var resources = new MockResources { - CredentialRepository = base.GetRepository(), - UseRepository = base.GetRepository(), + CredentialRepository = GetRepository(), + UseRepository = GetRepository(), FakeRedis = new Mock(), FakeConfiguration = new Mock(), CryptoHelper = new CryptoHelper(key, iv) diff --git a/Fin.Test/EmailSenders/EmailSenderServiceTest.cs b/Fin.Test/EmailSenders/EmailSenderServiceTest.cs index 9be4aec..aab3cc1 100644 --- a/Fin.Test/EmailSenders/EmailSenderServiceTest.cs +++ b/Fin.Test/EmailSenders/EmailSenderServiceTest.cs @@ -2,6 +2,7 @@ using Fin.Infrastructure.EmailSenders; using Fin.Infrastructure.EmailSenders.Constants; using Fin.Infrastructure.EmailSenders.Dto; +using Fin.Infrastructure.EmailSenders.MailKit; using Fin.Infrastructure.EmailSenders.MailSender; using FluentAssertions; using Microsoft.Extensions.Configuration; @@ -13,7 +14,9 @@ public class EmailSenderServiceTest { private readonly Mock _configurationMock = new(); private readonly Mock _mailSenderClientMock = new(); + private readonly Mock _mailKitClientMock = new(); private readonly Mock _mailServiceSectionMock = new(); + private readonly Mock _emailTemplateServiceMock = new(); #region SendEmailAsync - MailSender @@ -233,7 +236,7 @@ await act.Should().ThrowAsync() private EmailSenderService GetService() { - return new EmailSenderService(_configurationMock.Object, _mailSenderClientMock.Object); + return new EmailSenderService(_configurationMock.Object, _mailSenderClientMock.Object, _mailKitClientMock.Object, _emailTemplateServiceMock.Object); } private SendEmailDto GetValidSendEmailDto() From 8056a1b194a1375638efa714c0226a25d69537b0 Mon Sep 17 00:00:00 2001 From: RafaelKC Date: Tue, 2 Dec 2025 20:59:12 -0300 Subject: [PATCH 08/14] FIN-76 adjusted tests --- Fin-Backend.sln.DotSettings.user | 8 +- .../AuthenticationServiceTest.cs | 3 +- .../EmailSenders/EmailSenderServiceTest.cs | 416 +++++++++++++++++- Fin.Test/Users/UserCreateServiceTest.cs | 4 + 4 files changed, 424 insertions(+), 7 deletions(-) diff --git a/Fin-Backend.sln.DotSettings.user b/Fin-Backend.sln.DotSettings.user index f5160e7..18eaaeb 100644 --- a/Fin-Backend.sln.DotSettings.user +++ b/Fin-Backend.sln.DotSettings.user @@ -13,11 +13,11 @@ ForceIncluded ForceIncluded ForceIncluded - <SessionState ContinuousTestingMode="0" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Solution /> + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from Solution" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Solution /> </SessionState> - <SessionState ContinuousTestingMode="0" IsActive="True" Name="MailSenderClientTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> - <Project Location="\home\rafaelchicovis\git\fin-backend\Fin.Test" Presentation="&lt;Fin.Test&gt;" /> + <SessionState ContinuousTestingMode="0" Name="MailSenderClientTest" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Project Location="/home/rafaelchicovis/git/fin-backend/Fin.Test" Presentation="&lt;Fin.Test&gt;" /> </SessionState> True False diff --git a/Fin.Test/Authentications/AuthenticationServiceTest.cs b/Fin.Test/Authentications/AuthenticationServiceTest.cs index 12ff5b9..16c2f3c 100644 --- a/Fin.Test/Authentications/AuthenticationServiceTest.cs +++ b/Fin.Test/Authentications/AuthenticationServiceTest.cs @@ -63,7 +63,8 @@ public async Task SendResetPasswordEmail_ExistEmail() .Verify(c => c.SetAsync($"reset-token-{credential.ResetToken}", credential.UserId, It.IsAny()), Times.Once);; resources.FakeEmailSender - .Verify(e => e.SendEmailAsync(It.Is(dto => dto.ToEmail == email && dto.HtmlBody.Contains(credential.ResetToken)), It.IsAny()), Times.Once); + .Verify(e => e.SendEmailAsync(It.Is(dto => dto.ToEmail == email), + It.IsAny()), Times.Once); } [Fact] diff --git a/Fin.Test/EmailSenders/EmailSenderServiceTest.cs b/Fin.Test/EmailSenders/EmailSenderServiceTest.cs index aab3cc1..6b01ec4 100644 --- a/Fin.Test/EmailSenders/EmailSenderServiceTest.cs +++ b/Fin.Test/EmailSenders/EmailSenderServiceTest.cs @@ -1,5 +1,4 @@ using Fin.Application.Emails; -using Fin.Infrastructure.EmailSenders; using Fin.Infrastructure.EmailSenders.Constants; using Fin.Infrastructure.EmailSenders.Dto; using Fin.Infrastructure.EmailSenders.MailKit; @@ -190,7 +189,7 @@ public async Task SendEmailAsync_ShouldHandle_EmailWithUnicodeCharacters() { ToEmail = "user@test.com", ToName = "Usuário Tëst", - Subject = "Assunto com çãão", + Subject = "Assunto com ção", PlainBody = "Corpo com ãéíóú", HtmlBody = "

HTML com émojis 😀🎉

" }; @@ -208,6 +207,393 @@ public async Task SendEmailAsync_ShouldHandle_EmailWithUnicodeCharacters() #endregion + #region SendEmailAsync - Template Integration + + [Fact] + public async Task SendEmailAsync_ShouldNotUseTemplate_WhenBaseTemplatesNameIsNull() + { + // Arrange + SetupMailService(MailServicesConst.MailSender); + var service = GetService(); + var dto = GetValidSendEmailDto(); + dto.BaseTemplatesName = null; + + _mailSenderClientMock + .Setup(m => m.SendEmailAsync(dto, default)) + .ReturnsAsync(true); + + // Act + await service.SendEmailAsync(dto); + + // Assert + _emailTemplateServiceMock.Verify( + e => e.Get(It.IsAny(), It.IsAny>()), + Times.Never + ); + } + + [Fact] + public async Task SendEmailAsync_ShouldNotUseTemplate_WhenBaseTemplatesNameIsEmpty() + { + // Arrange + SetupMailService(MailServicesConst.MailSender); + var service = GetService(); + var dto = GetValidSendEmailDto(); + dto.BaseTemplatesName = ""; + + _mailSenderClientMock + .Setup(m => m.SendEmailAsync(dto, default)) + .ReturnsAsync(true); + + // Act + await service.SendEmailAsync(dto); + + // Assert + _emailTemplateServiceMock.Verify( + e => e.Get(It.IsAny(), It.IsAny>()), + Times.Never + ); + } + + [Fact] + public async Task SendEmailAsync_ShouldPopulateHtmlBody_WhenUsingTemplate() + { + // Arrange + SetupMailService(MailServicesConst.MailSender); + var service = GetService(); + var dto = new SendEmailDto + { + ToEmail = "user@test.com", + ToName = "Test User", + BaseTemplatesName = "Welcome", + TemplateProperties = new Dictionary + { + { "userName", "John" } + } + }; + + var expectedHtml = "Welcome, John!"; + _emailTemplateServiceMock + .Setup(e => e.Get("WelcomeHTML", dto.TemplateProperties)) + .Returns(expectedHtml); + + _mailSenderClientMock + .Setup(m => m.SendEmailAsync(It.IsAny(), default)) + .ReturnsAsync(true); + + // Act + await service.SendEmailAsync(dto); + + // Assert + dto.HtmlBody.Should().Be(expectedHtml); + _emailTemplateServiceMock.Verify( + e => e.Get("WelcomeHTML", dto.TemplateProperties), + Times.Once + ); + } + + [Fact] + public async Task SendEmailAsync_ShouldPopulatePlainBody_WhenUsingTemplate() + { + // Arrange + SetupMailService(MailServicesConst.MailSender); + var service = GetService(); + var dto = new SendEmailDto + { + ToEmail = "user@test.com", + ToName = "Test User", + BaseTemplatesName = "Welcome", + TemplateProperties = new Dictionary + { + { "userName", "John" } + } + }; + + var expectedPlain = "Welcome, John!"; + _emailTemplateServiceMock + .Setup(e => e.Get("WelcomePlain", dto.TemplateProperties)) + .Returns(expectedPlain); + + _mailSenderClientMock + .Setup(m => m.SendEmailAsync(It.IsAny(), default)) + .ReturnsAsync(true); + + // Act + await service.SendEmailAsync(dto); + + // Assert + dto.PlainBody.Should().Be(expectedPlain); + _emailTemplateServiceMock.Verify( + e => e.Get("WelcomePlain", dto.TemplateProperties), + Times.Once + ); + } + + [Fact] + public async Task SendEmailAsync_ShouldPopulateSubject_WhenUsingTemplate() + { + // Arrange + SetupMailService(MailServicesConst.MailSender); + var service = GetService(); + var dto = new SendEmailDto + { + ToEmail = "user@test.com", + ToName = "Test User", + BaseTemplatesName = "Welcome", + TemplateProperties = new Dictionary + { + { "userName", "John" } + } + }; + + var expectedSubject = "Welcome to Our Service, John!"; + _emailTemplateServiceMock + .Setup(e => e.Get("WelcomeSubject", dto.TemplateProperties)) + .Returns(expectedSubject); + + _mailSenderClientMock + .Setup(m => m.SendEmailAsync(It.IsAny(), default)) + .ReturnsAsync(true); + + // Act + await service.SendEmailAsync(dto); + + // Assert + dto.Subject.Should().Be(expectedSubject); + _emailTemplateServiceMock.Verify( + e => e.Get("WelcomeSubject", dto.TemplateProperties), + Times.Once + ); + } + + [Fact] + public async Task SendEmailAsync_ShouldPopulateAllFields_WhenUsingTemplate() + { + // Arrange + SetupMailService(MailServicesConst.MailSender); + var service = GetService(); + var dto = new SendEmailDto + { + ToEmail = "user@test.com", + ToName = "Test User", + BaseTemplatesName = "Welcome", + TemplateProperties = new Dictionary + { + { "userName", "John" }, + { "companyName", "Fin" } + } + }; + + var expectedHtml = "Welcome, John from Fin!"; + var expectedPlain = "Welcome, John from Fin!"; + var expectedSubject = "Welcome to Fin, John!"; + + _emailTemplateServiceMock + .Setup(e => e.Get("WelcomeHTML", dto.TemplateProperties)) + .Returns(expectedHtml); + _emailTemplateServiceMock + .Setup(e => e.Get("WelcomePlain", dto.TemplateProperties)) + .Returns(expectedPlain); + _emailTemplateServiceMock + .Setup(e => e.Get("WelcomeSubject", dto.TemplateProperties)) + .Returns(expectedSubject); + + _mailSenderClientMock + .Setup(m => m.SendEmailAsync(It.IsAny(), default)) + .ReturnsAsync(true); + + // Act + await service.SendEmailAsync(dto); + + // Assert + dto.HtmlBody.Should().Be(expectedHtml); + dto.PlainBody.Should().Be(expectedPlain); + dto.Subject.Should().Be(expectedSubject); + + _emailTemplateServiceMock.Verify( + e => e.Get("WelcomeHTML", dto.TemplateProperties), + Times.Once + ); + _emailTemplateServiceMock.Verify( + e => e.Get("WelcomePlain", dto.TemplateProperties), + Times.Once + ); + _emailTemplateServiceMock.Verify( + e => e.Get("WelcomeSubject", dto.TemplateProperties), + Times.Once + ); + } + + [Fact] + public async Task SendEmailAsync_ShouldNotOverrideHtmlBody_WhenAlreadyProvided() + { + // Arrange + SetupMailService(MailServicesConst.MailSender); + var service = GetService(); + var existingHtml = "Existing HTML"; + var dto = new SendEmailDto + { + ToEmail = "user@test.com", + ToName = "Test User", + BaseTemplatesName = "Welcome", + HtmlBody = existingHtml, + TemplateProperties = new Dictionary() + }; + + _emailTemplateServiceMock + .Setup(e => e.Get("WelcomeHTML", dto.TemplateProperties)) + .Returns("Template HTML"); + + _mailSenderClientMock + .Setup(m => m.SendEmailAsync(It.IsAny(), default)) + .ReturnsAsync(true); + + // Act + await service.SendEmailAsync(dto); + + // Assert + dto.HtmlBody.Should().Be(existingHtml); + _emailTemplateServiceMock.Verify( + e => e.Get("WelcomeHTML", It.IsAny>()), + Times.Never + ); + } + + [Fact] + public async Task SendEmailAsync_ShouldNotOverridePlainBody_WhenAlreadyProvided() + { + // Arrange + SetupMailService(MailServicesConst.MailSender); + var service = GetService(); + var existingPlain = "Existing Plain Text"; + var dto = new SendEmailDto + { + ToEmail = "user@test.com", + ToName = "Test User", + BaseTemplatesName = "Welcome", + PlainBody = existingPlain, + TemplateProperties = new Dictionary() + }; + + _emailTemplateServiceMock + .Setup(e => e.Get("WelcomePlain", dto.TemplateProperties)) + .Returns("Template Plain Text"); + + _mailSenderClientMock + .Setup(m => m.SendEmailAsync(It.IsAny(), default)) + .ReturnsAsync(true); + + // Act + await service.SendEmailAsync(dto); + + // Assert + dto.PlainBody.Should().Be(existingPlain); + _emailTemplateServiceMock.Verify( + e => e.Get("WelcomePlain", It.IsAny>()), + Times.Never + ); + } + + [Fact] + public async Task SendEmailAsync_ShouldNotOverrideSubject_WhenAlreadyProvided() + { + // Arrange + SetupMailService(MailServicesConst.MailSender); + var service = GetService(); + var existingSubject = "Existing Subject"; + var dto = new SendEmailDto + { + ToEmail = "user@test.com", + ToName = "Test User", + BaseTemplatesName = "Welcome", + Subject = existingSubject, + TemplateProperties = new Dictionary() + }; + + _emailTemplateServiceMock + .Setup(e => e.Get("WelcomeSubject", dto.TemplateProperties)) + .Returns("Template Subject"); + + _mailSenderClientMock + .Setup(m => m.SendEmailAsync(It.IsAny(), default)) + .ReturnsAsync(true); + + // Act + await service.SendEmailAsync(dto); + + // Assert + dto.Subject.Should().Be(existingSubject); + _emailTemplateServiceMock.Verify( + e => e.Get("WelcomeSubject", It.IsAny>()), + Times.Never + ); + } + + [Fact] + public async Task SendEmailAsync_ShouldInitializeTemplateProperties_WhenNull() + { + // Arrange + SetupMailService(MailServicesConst.MailSender); + var service = GetService(); + var dto = new SendEmailDto + { + ToEmail = "user@test.com", + ToName = "Test User", + BaseTemplatesName = "Welcome", + TemplateProperties = null + }; + + _emailTemplateServiceMock + .Setup(e => e.Get(It.IsAny(), It.IsAny>())) + .Returns("Template Content"); + + _mailSenderClientMock + .Setup(m => m.SendEmailAsync(It.IsAny(), default)) + .ReturnsAsync(true); + + // Act + await service.SendEmailAsync(dto); + + // Assert + dto.TemplateProperties.Should().NotBeNull(); + dto.TemplateProperties.Should().BeEmpty(); + } + + [Fact] + public async Task SendEmailAsync_ShouldUseEmptyDictionary_WhenTemplatePropertiesNotProvided() + { + // Arrange + SetupMailService(MailServicesConst.MailSender); + var service = GetService(); + var dto = new SendEmailDto + { + ToEmail = "user@test.com", + ToName = "Test User", + BaseTemplatesName = "Welcome" + }; + + var expectedHtml = "Welcome!"; + _emailTemplateServiceMock + .Setup(e => e.Get("WelcomeHTML", It.IsAny>())) + .Returns(expectedHtml); + + _mailSenderClientMock + .Setup(m => m.SendEmailAsync(It.IsAny(), default)) + .ReturnsAsync(true); + + // Act + await service.SendEmailAsync(dto); + + // Assert + dto.HtmlBody.Should().Be(expectedHtml); + _emailTemplateServiceMock.Verify( + e => e.Get("WelcomeHTML", It.Is>(d => d != null && d.Count == 0)), + Times.Once + ); + } + + #endregion + #region Exception Handling [Fact] @@ -230,6 +616,32 @@ await act.Should().ThrowAsync() .WithMessage("Test exception"); } + [Fact] + public async Task SendEmailAsync_ShouldPropagateException_WhenTemplateServiceThrows() + { + // Arrange + SetupMailService(MailServicesConst.MailSender); + var service = GetService(); + var dto = new SendEmailDto + { + ToEmail = "user@test.com", + ToName = "Test User", + BaseTemplatesName = "Welcome", + TemplateProperties = new Dictionary() + }; + + _emailTemplateServiceMock + .Setup(e => e.Get("WelcomeHTML", It.IsAny>())) + .Throws(new InvalidOperationException("Template not found")); + + // Act + Func act = async () => await service.SendEmailAsync(dto); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Template not found"); + } + #endregion #region Helper Methods diff --git a/Fin.Test/Users/UserCreateServiceTest.cs b/Fin.Test/Users/UserCreateServiceTest.cs index 183dc35..4b3ec89 100644 --- a/Fin.Test/Users/UserCreateServiceTest.cs +++ b/Fin.Test/Users/UserCreateServiceTest.cs @@ -553,6 +553,8 @@ public async Task CreateUser_Success() FirstName = TestUtils.Strings[5], BirthDate = DateOnly.FromDateTime(TestUtils.UtcDateTimes[1]), ImagePublicUrl = TestUtils.Strings[6], + Locale = "pt-Br", + Timezone = "America/Sao_Paulo" }; // Act @@ -623,6 +625,8 @@ public async Task CreateUser_WithGoogle_Success() FirstName = TestUtils.Strings[5], BirthDate = DateOnly.FromDateTime(TestUtils.UtcDateTimes[1]), ImagePublicUrl = TestUtils.Strings[6], + Locale = "pt-Br", + Timezone = "America/Sao_Paulo" }; var googleId = TestUtils.Strings[1]; From 60f184e230bdc8845cadea156a4155ff4af0f885 Mon Sep 17 00:00:00 2001 From: RafaelKC Date: Tue, 2 Dec 2025 21:02:46 -0300 Subject: [PATCH 09/14] FIN-76 adjusting mail kit client --- .../EmailSenders/MailKit/MailKitClientExtension.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Fin.Infrastructure/EmailSenders/MailKit/MailKitClientExtension.cs b/Fin.Infrastructure/EmailSenders/MailKit/MailKitClientExtension.cs index 3738008..5db0924 100644 --- a/Fin.Infrastructure/EmailSenders/MailKit/MailKitClientExtension.cs +++ b/Fin.Infrastructure/EmailSenders/MailKit/MailKitClientExtension.cs @@ -1,4 +1,3 @@ -using Fin.Infrastructure.EmailSenders.MailSender; using Microsoft.Extensions.DependencyInjection; namespace Fin.Infrastructure.EmailSenders.MailKit; @@ -7,7 +6,7 @@ public static class MailKitClientExtension { public static IServiceCollection AddMailKitClient(this IServiceCollection services) { - services.AddHttpClient(); + services.AddSingleton(); return services; } From e659ef6b5f6772b2eb8b62332208ba7a071b3b8f Mon Sep 17 00:00:00 2001 From: RafaelKC Date: Tue, 2 Dec 2025 21:13:18 -0300 Subject: [PATCH 10/14] FIN-76 adjusting resources --- Fin.Application/Fin.Application.csproj | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Fin.Application/Fin.Application.csproj b/Fin.Application/Fin.Application.csproj index 2ed4d2f..28d9054 100644 --- a/Fin.Application/Fin.Application.csproj +++ b/Fin.Application/Fin.Application.csproj @@ -22,6 +22,14 @@ + + + + + + + + From 132b507de2b071ca7ffb100ec98b02c7ca272ac3 Mon Sep 17 00:00:00 2001 From: RafaelKC Date: Tue, 2 Dec 2025 21:20:13 -0300 Subject: [PATCH 11/14] FIN-76 --- Fin.Application/Fin.Application.csproj | 51 ++++++++++++++------------ 1 file changed, 28 insertions(+), 23 deletions(-) diff --git a/Fin.Application/Fin.Application.csproj b/Fin.Application/Fin.Application.csproj index 28d9054..eb74c9f 100644 --- a/Fin.Application/Fin.Application.csproj +++ b/Fin.Application/Fin.Application.csproj @@ -6,37 +6,42 @@ - - + + - - - - - - + + + + + + - + - - - - - - - - - - True - True - EmailTemplates.resx - + + True + True + EmailTemplates.resx + + + + ResXFileCodeGenerator + EmailTemplates.Designer.cs + + + + EmailTemplates.resx + + + + EmailTemplates.resx + - + From 6673fcf55886231a08bc120942e9a065f48d5b78 Mon Sep 17 00:00:00 2001 From: RafaelKC Date: Tue, 2 Dec 2025 21:24:47 -0300 Subject: [PATCH 12/14] FIN-76 .. --- Fin.Api/Fin.Api.csproj | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Fin.Api/Fin.Api.csproj b/Fin.Api/Fin.Api.csproj index 4f9c60c..0961e25 100644 --- a/Fin.Api/Fin.Api.csproj +++ b/Fin.Api/Fin.Api.csproj @@ -1,10 +1,15 @@ - + net9.0 enable + + + + + From de50dfc01222dc53f79eca3ab90fd0379d6029e9 Mon Sep 17 00:00:00 2001 From: RafaelKC Date: Tue, 2 Dec 2025 21:27:56 -0300 Subject: [PATCH 13/14] FIN-76 ... --- Fin.Api/Fin.Api.csproj | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Fin.Api/Fin.Api.csproj b/Fin.Api/Fin.Api.csproj index 0961e25..cd13eaa 100644 --- a/Fin.Api/Fin.Api.csproj +++ b/Fin.Api/Fin.Api.csproj @@ -3,12 +3,8 @@ net9.0 enable + en - - - - - From b42d6f88a585adc0282241e4a125064897c7b621 Mon Sep 17 00:00:00 2001 From: RafaelKC Date: Tue, 2 Dec 2025 21:31:45 -0300 Subject: [PATCH 14/14] FIN-76 .... --- Fin.Application/Fin.Application.csproj | 1 + Fin.Test/Fin.Test.csproj | 1 + 2 files changed, 2 insertions(+) diff --git a/Fin.Application/Fin.Application.csproj b/Fin.Application/Fin.Application.csproj index eb74c9f..0e2a1f0 100644 --- a/Fin.Application/Fin.Application.csproj +++ b/Fin.Application/Fin.Application.csproj @@ -3,6 +3,7 @@ net9.0 enable + false diff --git a/Fin.Test/Fin.Test.csproj b/Fin.Test/Fin.Test.csproj index 84d8cae..11ad8f6 100644 --- a/Fin.Test/Fin.Test.csproj +++ b/Fin.Test/Fin.Test.csproj @@ -4,6 +4,7 @@ net9.0 enable false + en