Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
8d72048
Connecting to brapi.dev
sagustavo Nov 22, 2025
de163ef
Historical prices endpoint
sagustavo Nov 23, 2025
c80a064
Improving brapi.dev health check
sagustavo Nov 23, 2025
77866cc
Fix internal class unit testing
sagustavo Nov 23, 2025
0434704
Fix seq and rabbitmq ACA volume mounting
sagustavo Nov 23, 2025
9745176
Streaming and formatting the brapi quotes
sagustavo Nov 24, 2025
e303e48
Global response formatter
sagustavo Nov 27, 2025
1c30b83
Global error handler
sagustavo Nov 27, 2025
bc30ce8
Fix OutOfRangeException bug
sagustavo Nov 27, 2025
37e0fcf
Adding MongoDB connection
sagustavo Nov 28, 2025
f35d291
Fix brapi quote casting
sagustavo Nov 29, 2025
001d1e4
Creating Price mongo collection
sagustavo Nov 29, 2025
6166db0
Caching price data in MongoDB
sagustavo Nov 29, 2025
c43f2a1
Unit tests
sagustavo Nov 29, 2025
be90473
Fixing unit tests
sagustavo Nov 29, 2025
3107996
Merge branch 'main' into feat/market-data
sagustavo Nov 29, 2025
e7ee66c
Post-merge fix
sagustavo Nov 29, 2025
f7fa573
Stock Searching
sagustavo Nov 30, 2025
d9446f8
Idempotency on stock caching
sagustavo Nov 30, 2025
97965d2
Segregating Price and Stock repositories
sagustavo Nov 30, 2025
21372e5
Idempotency on prices appending
sagustavo Nov 30, 2025
742ade0
Stock cache TTL
sagustavo Nov 30, 2025
563661c
Pagination
sagustavo Nov 30, 2025
6e2f924
Unit tests
sagustavo Nov 30, 2025
aaa4338
Managed identity fix
sagustavo Dec 1, 2025
1598c75
Fix in-memory stock filtering when Sector null
sagustavo Dec 1, 2025
46ac619
Account creation
sagustavo Dec 1, 2025
a575623
DB migrations
sagustavo Dec 2, 2025
89636c3
E-mail confirmation
sagustavo Dec 5, 2025
c8a5788
Outbox pattern
sagustavo Dec 5, 2025
ed51bfb
Password reset endpoint
sagustavo Dec 5, 2025
ba6cd7b
Unit tests
sagustavo Dec 5, 2025
8aaead1
Sign in endpoint
sagustavo Dec 5, 2025
6c68c0b
Get Account Info endpoint
sagustavo Dec 5, 2025
d3331f0
Sign out endpoint
sagustavo Dec 5, 2025
efe53bb
Merge remote-tracking branch 'origin/main' into feat/access-account
sagustavo Dec 5, 2025
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
14 changes: 14 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,26 @@
<ItemGroup>
<PackageVersion Include="AspNetCore.HealthChecks.AzureKeyVault" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.MongoDb" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.MongoDb" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.Publisher.Seq" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.SqlServer" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.UI" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.Client" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<PackageVersion Include="AspNetCore.HealthChecks.Uris" Version="9.0.0" />
<PackageVersion Include="AutoFixture" Version="4.18.1" />
<PackageVersion Include="AutoFixture" Version="4.18.1" />
<PackageVersion Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.4.0" />
<PackageVersion Include="Azure.Identity" Version="1.17.0" />
<PackageVersion Include="Carter" Version="8.0.0" />
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
<PackageVersion Include="Mapster" Version="7.4.0" />
<PackageVersion Include="MassTransit.EntityFrameworkCore" Version="8.0.11" />
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.0.11" />
<PackageVersion Include="MassTransit.EntityFrameworkCore" Version="8.0.11" />
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.0.11" />
<PackageVersion Include="MediatR" Version="13.1.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.22" />
<PackageVersion Include="Microsoft.AspNetCore.Identity" Version="2.3.1" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.22" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="8.0.21" />
Expand All @@ -29,6 +34,12 @@
</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 @@ -37,6 +48,8 @@
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageVersion Include="MongoDB.Driver" Version="3.5.2" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="MongoDB.Driver" Version="3.5.2" />
<PackageVersion Include="Moq" Version="4.20.72" />
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="Polly" Version="8.6.4" />
<PackageVersion Include="Polly.Contrib.WaitAndRetry" Version="1.1.1" />
Expand All @@ -54,6 +67,7 @@
<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
122 changes: 122 additions & 0 deletions src/Account/Business/UseCases/AccessAccountUseCase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Kairos.Account.Configuration;
using Kairos.Account.Domain;
using Kairos.Account.Infra;
using Kairos.Shared.Contracts;
using Kairos.Shared.Contracts.Account.AccessAccount;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;

namespace Kairos.Account.Business.UseCases;

internal sealed class AccessAccountUseCase(
IOptions<Settings> config,
ILogger<AccessAccountUseCase> logger,
SignInManager<Investor> identity,
AccountContext db
) : IRequestHandler<AccessAccountCommand, Output<string>>
{
readonly JwtOptions _settings = config.Value.Jwt;

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

using (logger.BeginScope(enrichers))
{
try
{
var id = input.Identifier;

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

if (account is null)
{
logger.LogWarning("Sign-in failed. Account not found.");
return Output<string>.PolicyViolation(["Identificador ou senha inválidos."]);
}

var result = await identity.CheckPasswordSignInAsync(
account,
input.Password,
lockoutOnFailure: true);

if (result.IsLockedOut)
{
logger.LogWarning("Sign-in failed. Account is locked out.");
return Output<string>.PolicyViolation(["Esta conta está bloqueada. Tente novamente após 5 minutos."]);
}

if (result.IsNotAllowed)
{
logger.LogWarning("Sign-in failed. Email not confirmed.");
return Output<string>.PolicyViolation(["Confirme seu e-mail antes de acessar a conta."]);
}

if (result.Succeeded is false)
{
logger.LogWarning("Sign-in failed. Invalid password.");
return Output<string>.PolicyViolation(["Identificador ou senha inválidos."]);
}

logger.LogInformation("Sign-in successful. Generating token.");

var token = GenerateJwtToken(account);

return Output<string>.Ok(token, ["Autenticação realizada com sucesso!"]);
}
catch (Exception ex)
{
logger.LogError(ex, "An unexpected error occurred during sign-in.");
return Output<string>.UnexpectedError([ex.Message]);
}
}
}

string GenerateJwtToken(Investor account)
{
var tokenHandler = new JwtSecurityTokenHandler();
var key = Encoding.ASCII.GetBytes(_settings.Secret);

var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, account.Id.ToString()),
new(JwtRegisteredClaimNames.Email, account.Email!),
new(JwtRegisteredClaimNames.Name, account.Name),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};

var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(claims),
Expires = DateTime.UtcNow.AddMinutes(_settings.ExpiryMinutes),
Issuer = _settings.Issuer,
Audience = _settings.Audience,
SigningCredentials = new SigningCredentials(
new SymmetricSecurityKey(key),
SecurityAlgorithms.HmacSha256Signature)
};

var token = tokenHandler.CreateToken(tokenDescriptor);

return tokenHandler.WriteToken(token);
}
}
48 changes: 48 additions & 0 deletions src/Account/Business/UseCases/GetAccountInfoUseCase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using Kairos.Account.Infra;
using Kairos.Shared.Contracts;
using Kairos.Shared.Contracts.Account;
using Kairos.Shared.Contracts.Account.GetAccountInfo;
using MediatR;
using Microsoft.Extensions.Logging;

namespace Kairos.Account.Business.UseCases;

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

if (account is null)
{
logger.LogWarning(
"Attempted to access non-existent account with ID {AccountId}",
input.Id);
return Output<AccountInfo>.PolicyViolation(["Conta não encontrada."]);
}

return Output<AccountInfo>.Ok(new AccountInfo(
account.Id,
account.Name,
account.Birthdate,
account.Gender,
account.PhoneNumber ?? string.Empty,
account.Document,
account.Email!,
Address: null,
ProfilePicUrl: null
));
}
catch (Exception ex)
{
logger.LogError(ex, "An unexpected error occurred.");
return Output<AccountInfo>.UnexpectedError([
"Um erro inesperado ocorreu...",
ex.Message]);
}
}
}
59 changes: 58 additions & 1 deletion src/Account/DependencyInjection.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
using System.Reflection;
using System.Text;
using Kairos.Account.Configuration;
using Kairos.Account.Domain;
using Kairos.Account.Infra;
using Kairos.Account.Infra.Consumers;
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.Options;
using Microsoft.IdentityModel.Tokens;

namespace Kairos.Account;

Expand All @@ -16,16 +21,21 @@ public static class DependencyInjection
public static IServiceCollection AddAccount(
this IServiceCollection services,
IConfigurationManager config)
IConfigurationManager config)
{
services.Configure<Settings>(config);

return services
.AddIdentity(config)
.AddMediatR(cfg =>
{
cfg.LicenseKey = config["Keys:MediatR"];
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
});
})
.AddAuth();
}

public static IBusRegistrationConfigurator ConfigureAccountBus(this IBusRegistrationConfigurator x)
public static IBusRegistrationConfigurator ConfigureAccountBus(this IBusRegistrationConfigurator x)
{
x.AddConsumers(Assembly.GetExecutingAssembly());
Expand All @@ -34,6 +44,11 @@ public static IBusRegistrationConfigurator ConfigureAccountBus(this IBusRegistra
c.UseSqlServer();
c.UseBusOutbox();
});
x.AddEntityFrameworkOutbox<AccountContext>(c =>
{
c.UseSqlServer();
c.UseBusOutbox();
});

return x;
}
Expand Down Expand Up @@ -81,8 +96,50 @@ static IServiceCollection AddIdentity(
o.User.RequireUniqueEmail = true;
})
.AddEntityFrameworkStores<AccountContext>()
.AddSignInManager()
.AddDefaultTokenProviders();

return services;
}

static IServiceCollection AddAuth(this IServiceCollection services)
{
var jwt = services
.BuildServiceProvider()
.GetRequiredService<IOptions<Settings>>()
.Value.Jwt;

services
.AddAuthentication(o =>
{
o.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
o.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(o =>
{
o.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = jwt.Issuer,
ValidAudience = jwt.Audience,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Secret))
};

o.Events = new JwtBearerEvents
{
OnMessageReceived = context =>
{
context.Token = context.Request.Cookies[jwt.CookieName];
return Task.CompletedTask;
}
};
});

services.AddAuthorization();

return services;
}
}
2 changes: 1 addition & 1 deletion src/Account/Domain/Investor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ internal sealed class Investor : KairosAccount
public string Name { get; private set; }
public string Document { get; private set; }
public DateTime Birthdate { get; private set; }
public Gender Gender { get; private set; }
public Gender Gender { get; }
public PersonType Type { get; private set; }

Investor(
Expand Down
10 changes: 10 additions & 0 deletions src/Account/Infra/Configuration/JwtOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Kairos.Account.Configuration;

public sealed class JwtOptions
{
public required string CookieName { get; init; }
public required string Secret { get; init; }
public required string Issuer { get; init; }
public required string Audience { get; init; }
public required int ExpiryMinutes { get; init; }
}
6 changes: 6 additions & 0 deletions src/Account/Infra/Configuration/Settings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Kairos.Account.Configuration;

internal partial class Settings
{
public required JwtOptions Jwt { get; init; }
}
2 changes: 2 additions & 0 deletions src/Account/Kairos.Account.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
<ItemGroup>
<ProjectReference Include="../Shared/Kairos.Shared.csproj" />
<InternalsVisibleTo Include="Kairos.Account.UnitTests" />
<InternalsVisibleTo Include="Kairos.Gateway" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.Identity" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" />
</ItemGroup>
Expand Down
Loading
Loading