Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 1 addition & 7 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,6 @@
</PackageVersion>
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.22" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.22" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.22">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageVersion>
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.22" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.22" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.2" />
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="8.0.2" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.3" />
Expand All @@ -49,6 +43,7 @@
<PackageVersion Include="Polly.Contrib.WaitAndRetry" Version="1.1.1" />
<PackageVersion Include="Refit" Version="8.0.0" />
<PackageVersion Include="Refit.HttpClientFactory" Version="8.0.0" />
<PackageVersion Include="SendGrid" Version="9.29.3" />
<PackageVersion Include="Serilog" Version="4.3.0" />
<PackageVersion Include="Serilog.AspNetCore" Version="8.0.3" />
<PackageVersion Include="Serilog.Enrichers.ClientInfo" Version="2.6.0" />
Expand All @@ -61,7 +56,6 @@
<PackageVersion Include="SonarAnalyzer.CSharp" Version="10.15.0.120848" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="9.0.6" />
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
<PackageVersion Include="System.Linq.Async" Version="7.0.0" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.0" />
</ItemGroup>
Expand Down
11 changes: 11 additions & 0 deletions src/Account/Business/Command/DispatchAccountOpenedEmailCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using Kairos.Shared.Abstractions;

namespace Kairos.Account.Business.Command;

public sealed record DispatchAccountOpenedEmailCommand(
long Id,
string Name,
string Email,
string EmailConfirmationToken,
string PasswordResetToken,
Guid CorrelationId) : ICommand;
27 changes: 27 additions & 0 deletions src/Account/Business/SendGridEmailSender.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
using Kairos.Account.Configuration;
using Kairos.Account.Domain.Abstraction;
using Kairos.Account.Infra.Configuration;
using Microsoft.Extensions.Options;
using SendGrid;
using SendGrid.Helpers.Mail;

namespace Kairos.Account.Business;

internal sealed class SendGridEmailSender(IOptions<Settings> settings) : IEmailSender
{
readonly MailingOptions _options = settings.Value.Mailing;

public Task SendAsync(
string to,
string subject,
string htmlContent,
CancellationToken cancellationToken)
{
var client = new SendGridClient(_options.ApiKey);
var from = new EmailAddress(_options.FromEmail, _options.FromName);
var toAddress = new EmailAddress(to);
var msg = MailHelper.CreateSingleEmail(from, toAddress, subject, null, htmlContent);

return client.SendEmailAsync(msg, cancellationToken);
}
}
4 changes: 2 additions & 2 deletions src/Account/Business/UseCases/AccessAccountUseCase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public async Task<Output<string>> Handle(
if (account is null)
{
logger.LogWarning("Sign-in failed. Account not found.");
return Output<string>.PolicyViolation(["Identificador ou senha inválidos."]);
return Output<string>.PolicyViolation(["Credenciais inválidas."]);
}

var result = await identity.CheckPasswordSignInAsync(
Expand All @@ -74,7 +74,7 @@ public async Task<Output<string>> Handle(
if (result.Succeeded is false)
{
logger.LogWarning("Sign-in failed. Invalid password.");
return Output<string>.PolicyViolation(["Identificador ou senha inválidos."]);
return Output<string>.PolicyViolation(["Credenciais inválidas."]);
}

logger.LogInformation("Sign-in successful. Generating token.");
Expand Down
91 changes: 91 additions & 0 deletions src/Account/Business/UseCases/AccountOpenedEmailDispatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
using Kairos.Account.Configuration;
using Kairos.Account.Domain.Abstraction;
using Kairos.Account.Infra.Configuration;
using Kairos.Shared.Contracts;
using MediatR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

namespace Kairos.Account.Business.Command;

internal sealed class AccountOpenedEmailDispatcher(
ILogger<AccountOpenedEmailDispatcher> logger,
IEmailSender emailSender,
IOptions<Settings> settings
) : IRequestHandler<DispatchAccountOpenedEmailCommand, Output>
{
readonly MailingOptions _options = settings.Value.Mailing;

public async Task<Output> Handle(
DispatchAccountOpenedEmailCommand input,
CancellationToken cancellationToken)
{
var enrichers = new Dictionary<string, object?>
{
["CorrelationId"] = input.CorrelationId,
["AccountId"] = input.Id,
};

using (logger.BeginScope(enrichers))
{
try
{
logger.LogInformation("Sending opened account confirmation e-mail");

var emailToken = Uri.EscapeDataString(input.EmailConfirmationToken);
var passwordToken = Uri.EscapeDataString(input.PasswordResetToken);
var baseUrl = _options.RedirectToBaseUrl.ToString();

var confirmationLink = $"{baseUrl}/login?accountId={input.Id}&emailToken={emailToken}&passToken={passwordToken}";

var emailBody = $"""
<!DOCTYPE html>
<html>
<head>
<title>Bem-vindo ao Kairos</title>
</head>
<body style="font-family: Arial, sans-serif; text-align: center; color: #333;">
<div style="width: 100%; max-width: 600px; margin: 20px auto; padding: 20px; border: 1px solid #ddd; border-radius: 10px;">
<h1 style="color: #000;">Bem-vindo ao Kairos, {input.Name.Split(" ")[0]}!</h1>
<p style="font-size: 16px;">Não deixe a oportunidade passar e confirme a abertura da sua conta clicando no botão abaixo.</p>
<a href="{confirmationLink}"
style="display: inline-block;
padding: 12px 24px;
margin: 20px 0;
font-size: 18px;
font-weight: bold;
color: #ffffff;
background-color: #1C1C1E;
border-radius: 5px;
text-decoration: none;">
Confirmar Abertura
</a>
<p style="font-size: 12px; color: #777;">Se você não criou essa conta, por favor, ignore este e-mail.</p>
</div>
</body>
</html>
""";

logger.LogInformation(
"Sending confirmation email to {Email} with link {Link}",
input.Email,
confirmationLink);

await emailSender.SendAsync(
input.Email,
"⏳ Kairos | Abertura de conta",
emailBody,
cancellationToken);

logger.LogInformation("Opened account confirmation e-mail sent");

return Output.Empty;
}
catch (Exception ex)
{
logger.LogError(ex, "An unexpected error occurred");
throw;
}
}
}
}
54 changes: 54 additions & 0 deletions src/Account/Business/UseCases/EditAccountUseCase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
using Kairos.Account.Infra;
using Kairos.Shared.Contracts;
using Kairos.Shared.Contracts.Account;
using MediatR;
using Microsoft.Extensions.Logging;

namespace Kairos.Account.Business.UseCases;

internal sealed class EditAccountUseCase(
AccountContext db,
ILogger<EditAccountUseCase> logger
) : IRequestHandler<EditAccountCommand, Output>
{
public async Task<Output> Handle(
EditAccountCommand input,
CancellationToken cancellationToken)
{
var enrichers = new Dictionary<string, object?>
{
["AccountId"] = input.Id,
["CorrelationId"] = input.CorrelationId
};

using (logger.BeginScope(enrichers))
{
try
{
var account = await db.Investors.FindAsync(input.Id, cancellationToken);

Check warning on line 28 in src/Account/Business/UseCases/EditAccountUseCase.cs

View workflow job for this annotation

GitHub Actions / Run Tests

Forward the 'cancellationToken' parameter to the 'FindAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)

if (account is null)
{
logger.LogWarning("Sign-in failed. Account not found.");
return Output.CredentialsRequired(["Credenciais inválidas."]);
}

// TODO: suportar upload de profile pic ao blob storage
account
.SetAddress(input.Address)
.SetGender(input.Gender);

await db.SaveChangesAsync(cancellationToken);

return Output.Empty;
}
catch (Exception ex)
{
logger.LogError(ex, "An unexpected error occurred while updating the account.");
return Output.UnexpectedError([
"Houve um erro inesperado na edição da conta... tente novamente mais tarde.",
ex.Message]);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@

internal sealed class GetAccountInfoUseCase(
ILogger<GetAccountInfoUseCase> logger,
AccountContext db) : IRequestHandler<GetAccountInfoQuery, Output<AccountInfo>>
AccountContext db) : IRequestHandler<GetAccountQuery, Output<AccountInfo>>
{
public async Task<Output<AccountInfo>> Handle(GetAccountInfoQuery input, CancellationToken cancellationToken)
public async Task<Output<AccountInfo>> Handle(GetAccountQuery input, CancellationToken cancellationToken)
{
try
{
var account = await db.Investors.FindAsync(input.Id, cancellationToken);

Check warning on line 18 in src/Account/Business/UseCases/GetAccountUseCase.cs

View workflow job for this annotation

GitHub Actions / Run Tests

Forward the 'cancellationToken' parameter to the 'FindAsync' method or pass in 'CancellationToken.None' explicitly to indicate intentionally not propagating the token (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016)

if (account is null)
{
Expand All @@ -28,12 +28,16 @@
return Output<AccountInfo>.Ok(new AccountInfo(
account.Id,
account.Name,
new AccountDocument(account.Document),
new AccountPhone(
account.PhoneNumber!,
account.PhoneNumberConfirmed),
new AccountEmail(
account.Email!,
account.EmailConfirmed),
account.Birthdate,
account.Gender,
account.PhoneNumber ?? string.Empty,
account.Document,
account.Email!,
Address: null,
Address: account.Address,
ProfilePicUrl: null
));
}
Expand Down
26 changes: 22 additions & 4 deletions src/Account/Business/UseCases/SetPasswordUseCase.cs
Original file line number Diff line number Diff line change
@@ -1,34 +1,47 @@
using Kairos.Account.Domain;
using Kairos.Account.Infra;
using Kairos.Shared.Contracts;
using Kairos.Shared.Contracts.Account;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Identifier = Kairos.Shared.Contracts.Account.AccessAccount.AccountIdentifier;

namespace Kairos.Account.Business.UseCases;

internal sealed class SetPasswordUseCase(
UserManager<Investor> identity,
ILogger<SetPasswordUseCase> logger
ILogger<SetPasswordUseCase> logger,
AccountContext db
) : IRequestHandler<SetPasswordCommand, Output>
{
public async Task<Output> Handle(SetPasswordCommand input, CancellationToken cancellationToken)
{
var enrichers = new Dictionary<string, object?>
{
["AccountId"] = input.AccountId, ["CorrelationId"] = input.CorrelationId
["AccountIdentifier"] = input.AccountIdentifier.Value,
["CorrelationId"] = input.CorrelationId
};

using (logger.BeginScope(enrichers))
{
try
{
var account = await identity.FindByIdAsync(input.AccountId.ToString());
var id = input.AccountIdentifier;

var account = await db.Investors.FirstOrDefaultAsync(
i =>
(id.Type == Identifier.Document && i.Document == id.Value) ||
(id.Type == Identifier.Email && i.Email == id.Value) ||
(id.Type == Identifier.PhoneNumber && i.PhoneNumber == id.Value) ||
(id.Type == Identifier.AccountNumber && i.Id.ToString() == id.Value),
cancellationToken);

if (account is null)
{
logger.LogWarning("Account not found");
return Output.PolicyViolation([$"A conta #{input.AccountId} não existe."]);
return Output.PolicyViolation([$"A conta com identificador #{input.AccountIdentifier.Value} não existe."]);
}

if (await identity.IsEmailConfirmedAsync(account) is false)
Expand All @@ -37,6 +50,11 @@ public async Task<Output> Handle(SetPasswordCommand input, CancellationToken can
return Output.PolicyViolation(["O e-mail da conta precisa ser confirmado primeiro."]);
}

if (input.Pass != input.PassConfirmation)
{
return Output.PolicyViolation(["A senha e sua confirmação não conferem."]);
}

var result = await identity.ResetPasswordAsync(
account,
input.Token,
Expand Down
32 changes: 30 additions & 2 deletions src/Account/DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
using System.Reflection;
using System.Text;
using Kairos.Account.Business;
using Kairos.Account.Configuration;
using Kairos.Account.Domain;
using Kairos.Account.Domain.Abstraction;
using Kairos.Account.Infra;
using Kairos.Account.Infra.Consumers;
using Kairos.Account.Infra.HealthChecks;
using Kairos.Shared.Contracts.Account;
using MassTransit;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

Expand All @@ -26,13 +30,19 @@

return services
.AddIdentity(config)
.AddServices(config)
.AddAuth()
.AddHealthCheck();
}

static IServiceCollection AddServices(this IServiceCollection services, IConfigurationManager config) =>
services
.AddMediatR(cfg =>
{
cfg.LicenseKey = config["Keys:MediatR"];
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
})
.AddAuth();
}
.AddTransient<IEmailSender, SendGridEmailSender>();

public static IBusRegistrationConfigurator ConfigureAccountBus(this IBusRegistrationConfigurator x)
{
Expand Down Expand Up @@ -140,4 +150,22 @@

return services;
}

static IServiceCollection AddHealthCheck(this IServiceCollection services)
{
services.AddHttpClient("SendGrid", (serviceProvider, client) =>
{
var settings = serviceProvider.GetRequiredService<IOptions<Settings>>().Value;
client.BaseAddress = new Uri("https://api.sendgrid.com/");

Check warning on line 159 in src/Account/DependencyInjection.cs

View workflow job for this annotation

GitHub Actions / Run Tests

Refactor your code not to use hardcoded absolute paths or URIs. (https://rules.sonarsource.com/csharp/RSPEC-1075)
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", settings.Mailing.ApiKey);
});

services.AddHealthChecks()
.AddCheck<SendGridHealthCheck>(
"sendgrid",
failureStatus: HealthStatus.Degraded);

return services;
}
}
Loading
Loading