Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 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
52bd81c
Open Account + Confirm Email + Define pass
sagustavo Dec 5, 2025
846215b
Merge branch 'main' into feat/mds-stock-search
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
12 changes: 12 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "8.0.22",
"commands": [
"dotnet-ef"
]
}
}
}
1 change: 1 addition & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,7 @@ dotnet_diagnostic.xUnit1051.severity = none
dotnet_diagnostic.CS1998.severity = none
dotnet_diagnostic.S2696.severity = none
dotnet_diagnostic.CA1849.severity = none
dotnet_diagnostic.CA1305.severity = none

[**/Migrations/*]
generated_code = true
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -401,4 +401,5 @@ FodyWeavers.xsd
*.sln.iml
.idea

.todo
.todo
.env*
12 changes: 10 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,18 @@
<PackageVersion Include="Carter" Version="8.0.0" />
<PackageVersion Include="FluentAssertions" Version="8.8.0" />
<PackageVersion Include="Mapster" Version="7.4.0" />
<PackageVersion Include="MassTransit.RabbitMQ" Version="8.5.5" />
<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.Identity" Version="2.3.1" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.22" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="8.0.21" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.21" />
<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 Down
18 changes: 15 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ This is a monorepo for Kairos back-end services. This repo contains (or will con

More details on the underlying architecture can be seen in [this figma file](https://www.figma.com/design/kCMWPCXieoRD1e3wMS74SC/Kairos?node-id=0-1&t=hoFPXx18zhdAWdhv-1).

- Kairos Broker app: https://capp-kairos-broker.salmonpebble-5905d3a4.eastus2.azurecontainerapps.io/docs
- Kairos RabbitMQ: https://capp-kairos-rabbitmq.salmonpebble-5905d3a4.eastus2.azurecontainerapps.io/
- Kairos Seq: https://capp-kairos-seq.salmonpebble-5905d3a4.eastus2.azurecontainerapps.io/
- Kairos Broker app: [Link](https://capp-kairos-broker.salmonpebble-5905d3a4.eastus2.azurecontainerapps.io/docs)
- Kairos RabbitMQ: [Link](https://capp-kairos-rabbitmq.salmonpebble-5905d3a4.eastus2.azurecontainerapps.io/)
- Kairos Seq: [Link](https://capp-kairos-seq.salmonpebble-5905d3a4.eastus2.azurecontainerapps.io/)

<img width="1476" height="1286" alt="High Level Architecture" src="https://github.com/user-attachments/assets/c34f642b-bd73-49c9-bd2c-5ebde48eb143" />

Expand Down Expand Up @@ -216,4 +216,16 @@ az containerapp create \
--resource-group kairos \
--environment cae-kairos \
--yaml .github/capp-kairos-broker.yml
```

# Database Migrations

Generate the migration:
```sh
dotnet ef migrations add MigrationName -p src/SpecificModule -s src/Gateway -o Infra/Migrations --context SpecificModuleContext
```

Apply the migrations:
```sh
dotnet ef database update -p src/SpecificModule -s src/Gateway --context SpecificModuleContext
```
73 changes: 73 additions & 0 deletions src/Account/Business/UseCases/ConfirmEmailUseCase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System;
using Kairos.Account.Domain;
using Kairos.Shared.Contracts;
using Kairos.Shared.Contracts.Account;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;

namespace Kairos.Account.Business.UseCases;

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

using (logger.BeginScope(enrichers))
{
try
{
return await ConfirmEmail(input);
}
catch (Exception ex)
{
logger.LogError(ex, "An unexpected error occurred");
return Output.UnexpectedError([
"Algum erro inesperado ocorreu... tente novamente mais tarde.",
ex.Message]);
}
}
}

async Task<Output> ConfirmEmail(ConfirmEmailCommand input)
{
if (input.AccountId is 0 || string.IsNullOrEmpty(input.ConfirmationToken))
{
return Output.InvalidInput(["A conta e seu token de confirmação devem ser especificados."]);
}

var account = await identity.FindByIdAsync(input.AccountId.ToString());

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

var confirmationResult = await identity.ConfirmEmailAsync(
account,
input.ConfirmationToken);

if (confirmationResult.Succeeded is false)
{
var errors = confirmationResult.Errors
.Select(e => e.Description)
.ToList();

logger.LogWarning("E-mail confirmation failed. Errors: {@Errors}", errors);
return Output.PolicyViolation(errors);
}

return Output.Ok([
"E-mail confirmado com sucesso!",
"Defina uma senha para acesso à conta."]);
}
}
103 changes: 83 additions & 20 deletions src/Account/Business/UseCases/OpenAccountUseCase.cs
Original file line number Diff line number Diff line change
@@ -1,22 +1,30 @@
using Kairos.Account.Domain;
using Kairos.Account.Infra;
using Kairos.Shared.Contracts;
using Kairos.Shared.Contracts.Account;
using MassTransit;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace Kairos.Account.Business.UseCases;

internal sealed class OpenAccountUseCase(
ILogger<OpenAccountUseCase> logger,
IBus bus
) : IRequestHandler<OpenAccount, Output>
IBus bus,
UserManager<Investor> identity,
AccountContext db
) : IRequestHandler<OpenAccountCommand, Output>
{
public async Task<Output> Handle(OpenAccount req, CancellationToken cancellationToken)
public async Task<Output> Handle(
OpenAccountCommand req,
CancellationToken cancellationToken)
{
var enrichers = new Dictionary<string, object?>
{
["CorrelationId"] = req.CorrelationId,
["Document"] = req.Document
["Email"] = req.Email,
};

using (logger.BeginScope(enrichers))
Expand All @@ -25,34 +33,89 @@ public async Task<Output> Handle(OpenAccount req, CancellationToken cancellation
{
logger.LogInformation("Starting account opening process");

await Task.Delay(3000, cancellationToken);
Investor? existingAccount = await db.Investors
.FirstOrDefaultAsync(
i =>
i.Email == req.Email ||
i.PhoneNumber == req.PhoneNumber ||
i.Document == req.Document,
cancellationToken);

var accountId = new Random().Next(1000, 9999999);
if (existingAccount is not null)
{
logger.LogWarning("Account identifier(s) already taken.");
return Output.PolicyViolation(["O e-mail, telefone e/ou documento já está(ão) em uso."]);
}

logger.LogInformation("Account {AccountId} created", accountId);
var openAccountResult = Investor.OpenAccount(
req.Name,
req.Document,
req.PhoneNumber,
req.Email,
req.Birthdate,
req.AcceptTerms
);

AccountOpened @event = new(
Id: accountId,
FirstName: req.FirstName,
LastName: req.LastName,
Document: req.Document,
Email: req.Email,
Birthdate: req.Birthdate);
if (openAccountResult.IsFailure)
{
return new Output(openAccountResult);
}

await bus.Publish(
@event,
ctx => ctx.CorrelationId = req.CorrelationId,
cancellationToken);
Investor investor = openAccountResult.Value!;

var identityResult = await identity.CreateAsync(investor);

if (identityResult.Succeeded is false)
{
var errors = identityResult.Errors.Select(e => e.Description).ToList();

logger.LogWarning("Account opening failed: {@Errors}", errors);

return Output.PolicyViolation(errors);
}

await RaiseEvent(req, investor, cancellationToken);

logger.LogInformation("Account {AccountId} opened!", investor.Id);

return Output.Created([
$"Conta de investimento {accountId} aberta!",
$"Conta de investimento #{investor.Id} aberta!",
"Confirme a abertura no e-mail que será enviado em instantes."]);
}
catch (Exception ex)
{
logger.LogError(ex, "An unexpected error occurred");
return Output.UnexpectedError(["Algum erro inesperado ocorreu... tente novamente mais tarde."]);
return Output.UnexpectedError([
"Algum erro inesperado ocorreu... tente novamente mais tarde.",
ex.Message]);
}
}
}

async Task RaiseEvent(
OpenAccountCommand req,
Investor investor,
CancellationToken cancellationToken)
{
var emailConfirmationToken = string.Empty;
var passwordResetToken = string.Empty;

await Task.WhenAll(
Task.Run(async () => emailConfirmationToken = await identity.GenerateEmailConfirmationTokenAsync(investor)),
Task.Run(async () => passwordResetToken = await identity.GeneratePasswordResetTokenAsync(investor)));

await bus.Publish(
new AccountOpened(
investor.Id,
req.Name,
req.PhoneNumber,
req.Document,
req.Email,
req.Birthdate,
emailConfirmationToken,
passwordResetToken,
req.CorrelationId),
ctx => ctx.CorrelationId = req.CorrelationId,
cancellationToken);
}
}
64 changes: 64 additions & 0 deletions src/Account/Business/UseCases/SetPasswordUseCase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using Kairos.Account.Domain;
using Kairos.Shared.Contracts;
using Kairos.Shared.Contracts.Account;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Logging;

namespace Kairos.Account.Business.UseCases;

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

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

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

if (await identity.IsEmailConfirmedAsync(account) is false)
{
logger.LogWarning("Attempted to set password for an unconfirmed email");
return Output.PolicyViolation(["O e-mail da conta precisa ser confirmado primeiro."]);
}

var result = await identity.ResetPasswordAsync(
account,
input.Token,
input.Pass);

if (result.Succeeded is false)
{
var errors = result.Errors.Select(e => e.Description).ToList();
logger.LogWarning("Failed to set password. Errors: {@Errors}", errors);
return Output.PolicyViolation(errors);
}

logger.LogInformation("Password successfully defined");
return Output.Ok(["Senha definida com sucesso!"]);
}
catch (Exception ex)
{
logger.LogError(ex, "An unexpected error occurred");
return Output.UnexpectedError([
"Algum erro inesperado ocorreu... tente novamente mais tarde.",
ex.Message]);
}
}
}
}
Loading
Loading