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
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