From 8d7204859946ac8cdcc4938a5cea7397825eaf6f Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sat, 22 Nov 2025 19:17:33 -0300 Subject: [PATCH 01/34] Connecting to brapi.dev --- .github/capp-kairos-broker.yml | 17 ----- Directory.Packages.props | 5 ++ Kairos.sln | 7 ++ src/Gateway/DependencyInjection.cs | 2 +- src/Gateway/Kairos.Gateway.csproj | 1 + src/Gateway/Modules/MarketDataModule.cs | 36 +++++++++ src/Gateway/Program.cs | 4 +- src/Gateway/appsettings.Local.json | 7 +- src/Gateway/appsettings.json | 12 +++ .../Configuration/BrapiHealthCheck.cs | 37 +++++++++ src/MarketData/Configuration/Settings.cs | 11 +++ src/MarketData/DependencyInjection.cs | 75 +++++++++++++++++++ src/MarketData/Infra/IBrapi.cs | 9 +++ src/MarketData/Kairos.MarketData.csproj | 13 ++++ src/Shared/Abstractions/IQuery.cs | 12 +++ src/Shared/Configuration/ApiOptions.cs | 23 ++++++ .../EventBusOptions.cs | 2 +- .../KeyVaultOptions.cs | 2 +- src/Shared/Configuration/ResilienceOptions.cs | 12 +++ .../Contracts/MarketData/GetAssetsQuery.cs | 13 ++++ src/Shared/DependencyInjection.cs | 2 +- .../Infra/HttpClient/QueryParamHttpHandler.cs | 23 ++++++ src/Shared/Kairos.Shared.csproj | 5 ++ 23 files changed, 306 insertions(+), 24 deletions(-) create mode 100644 src/Gateway/Modules/MarketDataModule.cs create mode 100644 src/MarketData/Configuration/BrapiHealthCheck.cs create mode 100644 src/MarketData/Configuration/Settings.cs create mode 100644 src/MarketData/DependencyInjection.cs create mode 100644 src/MarketData/Infra/IBrapi.cs create mode 100644 src/MarketData/Kairos.MarketData.csproj create mode 100644 src/Shared/Abstractions/IQuery.cs create mode 100644 src/Shared/Configuration/ApiOptions.cs rename src/Shared/{Settings => Configuration}/EventBusOptions.cs (71%) rename src/Shared/{Settings => Configuration}/KeyVaultOptions.cs (90%) create mode 100644 src/Shared/Configuration/ResilienceOptions.cs create mode 100644 src/Shared/Contracts/MarketData/GetAssetsQuery.cs create mode 100644 src/Shared/Infra/HttpClient/QueryParamHttpHandler.cs diff --git a/.github/capp-kairos-broker.yml b/.github/capp-kairos-broker.yml index 0e54f30..fef5208 100644 --- a/.github/capp-kairos-broker.yml +++ b/.github/capp-kairos-broker.yml @@ -15,17 +15,6 @@ properties: targetPort: 8080 allowInsecure: true - secrets: - - name: azure-tenant-id - keyVaultUrl: https://kv-kairos.vault.azure.net/secrets/sp-tenant-id - identity: "/subscriptions/28b743cd-805d-4451-ba1f-067df11cbafc/resourcegroups/kairos/providers/Microsoft.ManagedIdentity/userAssignedIdentities/kairos-service" - - name: azure-client-id - keyVaultUrl: https://kv-kairos.vault.azure.net/secrets/sp-client-id - identity: "/subscriptions/28b743cd-805d-4451-ba1f-067df11cbafc/resourcegroups/kairos/providers/Microsoft.ManagedIdentity/userAssignedIdentities/kairos-service" - - name: azure-client-secret - keyVaultUrl: https://kv-kairos.vault.azure.net/secrets/sp-client-secret - identity: "/subscriptions/28b743cd-805d-4451-ba1f-067df11cbafc/resourcegroups/kairos/providers/Microsoft.ManagedIdentity/userAssignedIdentities/kairos-service" - registries: - server: kairosfinance.azurecr.io identity: "/subscriptions/28b743cd-805d-4451-ba1f-067df11cbafc/resourcegroups/kairos/providers/Microsoft.ManagedIdentity/userAssignedIdentities/kairos-service" @@ -53,12 +42,6 @@ properties: env: - name: ASPNETCORE_ENVIRONMENT value: Staging - - name: AZURE_TENANT_ID - secretRef: azure-tenant-id - - name: AZURE_CLIENT_ID - secretRef: azure-client-id - - name: AZURE_CLIENT_SECRET - secretRef: azure-client-secret scale: minReplicas: 0 diff --git a/Directory.Packages.props b/Directory.Packages.props index 2a9e5b6..954d598 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,11 +19,16 @@ + + + + + diff --git a/Kairos.sln b/Kairos.sln index 4cae76b..e0ecd3e 100644 --- a/Kairos.sln +++ b/Kairos.sln @@ -26,6 +26,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4361FCFA EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kairos.Account.UnitTests", "tests\Account.UnitTests\Kairos.Account.UnitTests.csproj", "{76D3F07E-1A29-4EB7-BD4B-6D8D65B118C5}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kairos.MarketData", "src\MarketData\Kairos.MarketData.csproj", "{1CC788E6-AEC0-41C8-8096-7003084611BD}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -48,6 +50,10 @@ Global {76D3F07E-1A29-4EB7-BD4B-6D8D65B118C5}.Debug|Any CPU.Build.0 = Debug|Any CPU {76D3F07E-1A29-4EB7-BD4B-6D8D65B118C5}.Release|Any CPU.ActiveCfg = Release|Any CPU {76D3F07E-1A29-4EB7-BD4B-6D8D65B118C5}.Release|Any CPU.Build.0 = Release|Any CPU + {1CC788E6-AEC0-41C8-8096-7003084611BD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1CC788E6-AEC0-41C8-8096-7003084611BD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1CC788E6-AEC0-41C8-8096-7003084611BD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1CC788E6-AEC0-41C8-8096-7003084611BD}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -57,5 +63,6 @@ Global {1AE56B5C-8C5D-448F-85CA-17B5E3841084} = {4FC691C0-CFC2-497C-AF12-B105468E9BDF} {FAE8E1FC-4467-4C08-833A-E113A8C042E8} = {4FC691C0-CFC2-497C-AF12-B105468E9BDF} {76D3F07E-1A29-4EB7-BD4B-6D8D65B118C5} = {4361FCFA-A93A-422D-9C63-6328F9B0A8AC} + {1CC788E6-AEC0-41C8-8096-7003084611BD} = {4FC691C0-CFC2-497C-AF12-B105468E9BDF} EndGlobalSection EndGlobal diff --git a/src/Gateway/DependencyInjection.cs b/src/Gateway/DependencyInjection.cs index 3b69f4f..2ce9a4b 100644 --- a/src/Gateway/DependencyInjection.cs +++ b/src/Gateway/DependencyInjection.cs @@ -1,6 +1,6 @@ using Carter; using Kairos.Account; -using Kairos.Shared.Settings; +using Kairos.Shared.Configuration; using MassTransit; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; diff --git a/src/Gateway/Kairos.Gateway.csproj b/src/Gateway/Kairos.Gateway.csproj index 0b0e759..c7fa344 100644 --- a/src/Gateway/Kairos.Gateway.csproj +++ b/src/Gateway/Kairos.Gateway.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Gateway/Modules/MarketDataModule.cs b/src/Gateway/Modules/MarketDataModule.cs new file mode 100644 index 0000000..d68ea32 --- /dev/null +++ b/src/Gateway/Modules/MarketDataModule.cs @@ -0,0 +1,36 @@ +using Carter; +using Kairos.Shared.Contracts; +using Kairos.Shared.Contracts.MarketData; +using MediatR; +using Microsoft.AspNetCore.Mvc; + +namespace Kairos.Gateway.Modules; + +public sealed class MarketDataModule : CarterModule +{ + public MarketDataModule() : base("/api/v1/market-data") + { + WithTags("MarketData"); + } + + public override void AddRoutes(IEndpointRouteBuilder app) + { + app + .MapGet("/stocks", async (IMediator mediator, [FromQuery(Name = "q")] string[] searchTerms) => + { + GetAssetsQuery query = new(searchTerms); + + Output? res = await mediator.Send(query); + + if (res.IsFailure) + { + return Results.Json( + res, + statusCode: StatusCodes.Status500InternalServerError); + } + + return Results.Ok(res); + }) + .WithDescription("Get basic information about specific stocks"); + } +} \ No newline at end of file diff --git a/src/Gateway/Program.cs b/src/Gateway/Program.cs index f3cae85..290b925 100644 --- a/src/Gateway/Program.cs +++ b/src/Gateway/Program.cs @@ -2,6 +2,7 @@ using HealthChecks.UI.Client; using Kairos.Account; using Kairos.Gateway; +using Kairos.MarketData; using Kairos.Shared; using Microsoft.AspNetCore.Diagnostics.HealthChecks; @@ -20,7 +21,8 @@ builder.Configuration, builder.Host) .AddGateway(builder.Configuration) - .AddAccount(builder.Configuration); + .AddAccount(builder.Configuration) + .AddMarketData(builder.Configuration); } WebApplication app = builder.Build(); diff --git a/src/Gateway/appsettings.Local.json b/src/Gateway/appsettings.Local.json index ffe5764..7074222 100644 --- a/src/Gateway/appsettings.Local.json +++ b/src/Gateway/appsettings.Local.json @@ -4,12 +4,15 @@ "WriteTo": [ { "Name": "Seq", - "Args": { "serverUrl": "http://localhost:5341" } + "Args": { + "serverUrl": "https://capp-kairos-seq.salmonpebble-5905d3a4.eastus2.azurecontainerapps.io:5341", + "apiKey": "Serilog:WriteTo:0:Args:apiKey" + } }, { "Name": "Console" } ] }, - "Health": { "Seq": { "Url": "http://localhost:8080/health" } }, + "Health": { "Seq": { "Url": "https://capp-kairos-seq.salmonpebble-5905d3a4.eastus2.azurecontainerapps.io:5341/health" } }, "EventBus": { "HostAddress": "rabbitmq://dev:dev@localhost:5672", "Events": { diff --git a/src/Gateway/appsettings.json b/src/Gateway/appsettings.json index 9742f76..f42e4cc 100644 --- a/src/Gateway/appsettings.json +++ b/src/Gateway/appsettings.json @@ -4,6 +4,18 @@ "Keys": { "MediatR": "Keys:MediatR" }, + "Api": { + "Brapi": { + "BaseUrl": "https://brapi.dev/api/", + "Token": "Api:Brapi:Token", + "Timeout": 30, + "Resilience": { + "MedianFirstRetryDelay": 1, + "RetryCount": 3 + }, + "HealthCheckPath": "/quote/FIQE3" + } + }, "Database": { "Broker": { "ConnectionString": "Database:Broker:ConnectionString" diff --git a/src/MarketData/Configuration/BrapiHealthCheck.cs b/src/MarketData/Configuration/BrapiHealthCheck.cs new file mode 100644 index 0000000..19efa01 --- /dev/null +++ b/src/MarketData/Configuration/BrapiHealthCheck.cs @@ -0,0 +1,37 @@ +using Kairos.Shared.Configuration; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; + +namespace Kairos.MarketData.Configuration; + +internal sealed class BrapiHealthCheck( + IOptions api, + IHttpClientFactory clientFactory) : IHealthCheck +{ + readonly ApiOptions _brapi = api.Value.Brapi; + public const string HttpClientName = "brapi-hc"; + + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + var client = clientFactory.CreateClient(HttpClientName); + + HttpResponseMessage? res = await client.GetAsync(_brapi.HealthCheckPath, cancellationToken); + + string result = $"Status Code {res.StatusCode}"; + + if (res.IsSuccessStatusCode is false) + { + return HealthCheckResult.Unhealthy(result); + } + + return HealthCheckResult.Healthy(result); + } + catch (Exception ex) + { + return HealthCheckResult.Unhealthy(exception: ex); + } + } +} diff --git a/src/MarketData/Configuration/Settings.cs b/src/MarketData/Configuration/Settings.cs new file mode 100644 index 0000000..bbb335b --- /dev/null +++ b/src/MarketData/Configuration/Settings.cs @@ -0,0 +1,11 @@ +using Kairos.Shared.Configuration; + +namespace Kairos.MarketData.Configuration; + +public static partial class Settings +{ + public sealed partial class Api + { + public required ApiOptions Brapi { get; init; } + } +} \ No newline at end of file diff --git a/src/MarketData/DependencyInjection.cs b/src/MarketData/DependencyInjection.cs new file mode 100644 index 0000000..ac374e5 --- /dev/null +++ b/src/MarketData/DependencyInjection.cs @@ -0,0 +1,75 @@ +using Kairos.MarketData.Configuration; +using Kairos.MarketData.Infra; +using Kairos.Shared.Configuration; +using Kairos.Shared.Infra.HttpClient; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Polly; +using Polly.Contrib.WaitAndRetry; +using Refit; + +namespace Kairos.MarketData; + +public static class DependencyInjection +{ + public static IServiceCollection AddMarketData( + this IServiceCollection services, + IConfigurationManager config) + { + services.Configure(config.GetSection("Api")); + + var api = services.BuildServiceProvider() + .GetRequiredService>() + .Value; + + return services + .AddApiClients(api) + .AddHealthCheck(api); + } + + static IServiceCollection AddApiClients(this IServiceCollection services, Settings.Api api) + { + var brapi = api.Brapi; + + services.AddRefitClient() + .ConfigureHttpClient(c => + { + c.BaseAddress = new Uri(brapi.BaseUrl); + c.Timeout = TimeSpan.FromSeconds(brapi.Timeout); + }) + .SetupHttpClient(brapi); + + return services; + } + + static IServiceCollection AddHealthCheck(this IServiceCollection services, Settings.Api api) + { + var brapi = api.Brapi; + + services + .AddHttpClient(BrapiHealthCheck.HttpClientName, c => + { + c.BaseAddress = new Uri(brapi.BaseUrl); + c.Timeout = TimeSpan.FromSeconds(brapi.Timeout); + }) + .SetupHttpClient(brapi); + + services.AddHealthChecks().AddCheck("brapi"); + + return services; + } + + static IHttpClientBuilder SetupHttpClient(this IHttpClientBuilder builder, ApiOptions api) => + builder + .AddHttpMessageHandler(() => new QueryParamHttpHandler("token", api.Token)) + .AddTransientHttpErrorPolicy(policyBuilder => + { + IEnumerable jitteredDelays = Backoff.DecorrelatedJitterBackoffV2( + TimeSpan.FromSeconds(api.Resilience.MedianFirstRetryDelay), + api.Resilience.RetryCount + ); + + return policyBuilder.WaitAndRetryAsync(jitteredDelays); + }); +} diff --git a/src/MarketData/Infra/IBrapi.cs b/src/MarketData/Infra/IBrapi.cs new file mode 100644 index 0000000..f422da7 --- /dev/null +++ b/src/MarketData/Infra/IBrapi.cs @@ -0,0 +1,9 @@ +using Refit; + +namespace Kairos.MarketData.Infra; + +internal interface IBrapi +{ + [Get("/stocks")] + public Task GetStocks(); +} \ No newline at end of file diff --git a/src/MarketData/Kairos.MarketData.csproj b/src/MarketData/Kairos.MarketData.csproj new file mode 100644 index 0000000..a42b9c7 --- /dev/null +++ b/src/MarketData/Kairos.MarketData.csproj @@ -0,0 +1,13 @@ + + + + + + + + net8.0 + enable + enable + + + diff --git a/src/Shared/Abstractions/IQuery.cs b/src/Shared/Abstractions/IQuery.cs new file mode 100644 index 0000000..d5eec6c --- /dev/null +++ b/src/Shared/Abstractions/IQuery.cs @@ -0,0 +1,12 @@ +using Kairos.Shared.Contracts; +using MediatR; + +namespace Kairos.Shared.Abstractions; + +/// +/// Represents a data retrieval request +/// +public interface IQuery : IRequest> +{ + public Guid CorrelationId { get; } +} diff --git a/src/Shared/Configuration/ApiOptions.cs b/src/Shared/Configuration/ApiOptions.cs new file mode 100644 index 0000000..14316f3 --- /dev/null +++ b/src/Shared/Configuration/ApiOptions.cs @@ -0,0 +1,23 @@ +namespace Kairos.Shared.Configuration; + +/// +/// Represents a config inside the appsettings Api section +/// +public sealed class ApiOptions +{ + public required string BaseUrl { get; init; } + + /// + /// Authentication token + /// + public string Token { get; init; } = string.Empty; + + /// + /// Request timeout in seconds + /// + public required int Timeout { get; init; } + + public required ResilienceOptions Resilience { get; init; } + + public string HealthCheckPath { get; init; } = string.Empty; +} \ No newline at end of file diff --git a/src/Shared/Settings/EventBusOptions.cs b/src/Shared/Configuration/EventBusOptions.cs similarity index 71% rename from src/Shared/Settings/EventBusOptions.cs rename to src/Shared/Configuration/EventBusOptions.cs index adfe13b..2a97942 100644 --- a/src/Shared/Settings/EventBusOptions.cs +++ b/src/Shared/Configuration/EventBusOptions.cs @@ -1,4 +1,4 @@ -namespace Kairos.Shared.Settings; +namespace Kairos.Shared.Configuration; public sealed record EventBusOptions { diff --git a/src/Shared/Settings/KeyVaultOptions.cs b/src/Shared/Configuration/KeyVaultOptions.cs similarity index 90% rename from src/Shared/Settings/KeyVaultOptions.cs rename to src/Shared/Configuration/KeyVaultOptions.cs index 7000eca..70404ad 100644 --- a/src/Shared/Settings/KeyVaultOptions.cs +++ b/src/Shared/Configuration/KeyVaultOptions.cs @@ -1,6 +1,6 @@ using System; -namespace Kairos.Shared.Settings; +namespace Kairos.Shared.Configuration; public sealed class KeyVaultOptions { diff --git a/src/Shared/Configuration/ResilienceOptions.cs b/src/Shared/Configuration/ResilienceOptions.cs new file mode 100644 index 0000000..e38ba62 --- /dev/null +++ b/src/Shared/Configuration/ResilienceOptions.cs @@ -0,0 +1,12 @@ +using Polly.Contrib.WaitAndRetry; + +namespace Kairos.Shared.Configuration; + +/// +/// Main parameters of +/// +public sealed class ResilienceOptions +{ + public required double MedianFirstRetryDelay { get; init; } + public required int RetryCount { get; init; } +} \ No newline at end of file diff --git a/src/Shared/Contracts/MarketData/GetAssetsQuery.cs b/src/Shared/Contracts/MarketData/GetAssetsQuery.cs new file mode 100644 index 0000000..ab24ec3 --- /dev/null +++ b/src/Shared/Contracts/MarketData/GetAssetsQuery.cs @@ -0,0 +1,13 @@ +using Kairos.Shared.Abstractions; + +namespace Kairos.Shared.Contracts.MarketData; + +public sealed record GetAssetsQuery( + Guid CorrelationId, + IEnumerable SearchTerms +) : IQuery +{ + public GetAssetsQuery(IEnumerable searchTerms) : this(Guid.NewGuid(), searchTerms) + { + } +} \ No newline at end of file diff --git a/src/Shared/DependencyInjection.cs b/src/Shared/DependencyInjection.cs index c476c16..9e09c05 100644 --- a/src/Shared/DependencyInjection.cs +++ b/src/Shared/DependencyInjection.cs @@ -1,7 +1,7 @@ using Azure.Identity; using HealthChecks.AzureKeyVault; using Kairos.Shared.Infra; -using Kairos.Shared.Settings; +using Kairos.Shared.Configuration; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Shared/Infra/HttpClient/QueryParamHttpHandler.cs b/src/Shared/Infra/HttpClient/QueryParamHttpHandler.cs new file mode 100644 index 0000000..884f484 --- /dev/null +++ b/src/Shared/Infra/HttpClient/QueryParamHttpHandler.cs @@ -0,0 +1,23 @@ +namespace Kairos.Shared.Infra.HttpClient; + +public sealed class QueryParamHttpHandler(string paramName, string paramValue) : DelegatingHandler +{ + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(paramValue) && request.RequestUri is not null) + { + var uri = request.RequestUri; + + if (!uri.Query.Contains($"{paramName}=", StringComparison.OrdinalIgnoreCase)) + { + var separator = string.IsNullOrEmpty(uri.Query) ? "?" : "&"; + var queryParam = $"{paramName}={Uri.EscapeDataString(paramValue)}"; + request.RequestUri = new Uri(uri + separator + queryParam); + } + } + + return base.SendAsync(request, cancellationToken); + } +} \ No newline at end of file diff --git a/src/Shared/Kairos.Shared.csproj b/src/Shared/Kairos.Shared.csproj index e24616f..a9d4632 100644 --- a/src/Shared/Kairos.Shared.csproj +++ b/src/Shared/Kairos.Shared.csproj @@ -12,6 +12,11 @@ + + + + + From de163ef3e2bb4affa3f24d490524a8268d7ff7e0 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sun, 23 Nov 2025 11:15:30 -0300 Subject: [PATCH 02/34] Historical prices endpoint --- .../Business/UseCases/OpenAccountUseCase.cs | 2 +- src/Account/DependencyInjection.cs | 7 ++- src/Account/Kairos.Account.csproj | 1 + src/Gateway/Dockerfile | 1 + src/Gateway/Modules/MarketDataModule.cs | 26 ++++++-- .../Business/UseCases/GetQuotesUseCase.cs | 33 +++++++++++ .../Configuration/BrapiHealthCheck.cs | 14 ++--- src/MarketData/DependencyInjection.cs | 38 ++++++++---- src/MarketData/Infra/IBrapi.cs | 8 ++- src/MarketData/Kairos.MarketData.csproj | 4 ++ .../GetStockQuotes/GetQuotesQuery.cs | 13 ++++ .../GetStockQuotes/QuoteResponse.cs | 10 ++++ .../MarketData/GetStockQuotes/StockDetail.cs | 59 +++++++++++++++++++ .../MarketData/GetStockQuotes/StockQuote.cs | 21 +++++++ .../{GetAssetsQuery.cs => GetStocksQuery.cs} | 4 +- 15 files changed, 210 insertions(+), 31 deletions(-) create mode 100644 src/MarketData/Business/UseCases/GetQuotesUseCase.cs create mode 100644 src/Shared/Contracts/MarketData/GetStockQuotes/GetQuotesQuery.cs create mode 100644 src/Shared/Contracts/MarketData/GetStockQuotes/QuoteResponse.cs create mode 100644 src/Shared/Contracts/MarketData/GetStockQuotes/StockDetail.cs create mode 100644 src/Shared/Contracts/MarketData/GetStockQuotes/StockQuote.cs rename src/Shared/Contracts/MarketData/{GetAssetsQuery.cs => GetStocksQuery.cs} (64%) diff --git a/src/Account/Business/UseCases/OpenAccountUseCase.cs b/src/Account/Business/UseCases/OpenAccountUseCase.cs index 9723210..bf256d6 100644 --- a/src/Account/Business/UseCases/OpenAccountUseCase.cs +++ b/src/Account/Business/UseCases/OpenAccountUseCase.cs @@ -6,7 +6,7 @@ namespace Kairos.Account.Business.UseCases; -public sealed class OpenAccountUseCase( +internal sealed class OpenAccountUseCase( ILogger logger, IBus bus ) : IRequestHandler diff --git a/src/Account/DependencyInjection.cs b/src/Account/DependencyInjection.cs index 10e9196..f098d3d 100644 --- a/src/Account/DependencyInjection.cs +++ b/src/Account/DependencyInjection.cs @@ -1,4 +1,5 @@ -using Kairos.Account.Infra.Consumers; +using System.Reflection; +using Kairos.Account.Infra.Consumers; using Kairos.Shared.Contracts.Account; using MassTransit; using Microsoft.Extensions.Configuration; @@ -15,13 +16,13 @@ public static IServiceCollection AddAccount( return services.AddMediatR(cfg => { cfg.LicenseKey = config["Keys:MediatR"]; - cfg.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly); + cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); }); } public static IBusRegistrationConfigurator AddAccountConsumers(this IBusRegistrationConfigurator x) { - x.AddConsumers(typeof(DependencyInjection).Assembly); + x.AddConsumers(Assembly.GetExecutingAssembly()); return x; } diff --git a/src/Account/Kairos.Account.csproj b/src/Account/Kairos.Account.csproj index 2b52f47..3d283d1 100644 --- a/src/Account/Kairos.Account.csproj +++ b/src/Account/Kairos.Account.csproj @@ -6,5 +6,6 @@ + \ No newline at end of file diff --git a/src/Gateway/Dockerfile b/src/Gateway/Dockerfile index 009a11a..7a5b508 100644 --- a/src/Gateway/Dockerfile +++ b/src/Gateway/Dockerfile @@ -7,6 +7,7 @@ COPY ["Directory.Packages.props", "."] COPY ["Kairos.sln", "."] COPY ["src/Gateway/Kairos.Gateway.csproj", "src/Gateway/"] COPY ["src/Account/Kairos.Account.csproj", "src/Account/"] +COPY ["src/MarketData/Kairos.MarketData.csproj", "src/MarketData/"] COPY ["src/Shared/Kairos.Shared.csproj", "src/Shared/"] COPY ["tests/Account.UnitTests/Kairos.Account.UnitTests.csproj", "tests/Account.UnitTests/"] diff --git a/src/Gateway/Modules/MarketDataModule.cs b/src/Gateway/Modules/MarketDataModule.cs index d68ea32..9b297fb 100644 --- a/src/Gateway/Modules/MarketDataModule.cs +++ b/src/Gateway/Modules/MarketDataModule.cs @@ -1,6 +1,7 @@ using Carter; using Kairos.Shared.Contracts; using Kairos.Shared.Contracts.MarketData; +using Kairos.Shared.Contracts.MarketData.GetStockQuotes; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -16,11 +17,9 @@ public MarketDataModule() : base("/api/v1/market-data") public override void AddRoutes(IEndpointRouteBuilder app) { app - .MapGet("/stocks", async (IMediator mediator, [FromQuery(Name = "q")] string[] searchTerms) => + .MapGet("/stocks", async (IMediator mediator, [FromQuery] string[] search) => { - GetAssetsQuery query = new(searchTerms); - - Output? res = await mediator.Send(query); + Output? res = await mediator.Send(new GetStocksQuery(search)); if (res.IsFailure) { @@ -31,6 +30,23 @@ public override void AddRoutes(IEndpointRouteBuilder app) return Results.Ok(res); }) - .WithDescription("Get basic information about specific stocks"); + .WithDescription("Get basic information about the specified stock(s)"); + + app.MapGet( + "/stocks/{ticker}/quote", + async (HttpContext http, IMediator mediator, [FromRoute] string ticker) => + { + var res = await mediator.Send(new GetQuotesQuery(ticker)); + + if (res.IsFailure) + { + return Results.Json( + res, + statusCode: StatusCodes.Status500InternalServerError); + } + + return Results.Ok(res); + }) + .WithDescription("Get a stock's historical prices"); } } \ No newline at end of file diff --git a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs new file mode 100644 index 0000000..f39a094 --- /dev/null +++ b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs @@ -0,0 +1,33 @@ +using Kairos.MarketData.Infra; +using Kairos.Shared.Contracts; +using Kairos.Shared.Contracts.MarketData.GetStockQuotes; +using MediatR; + +namespace Kairos.MarketData.Business.UseCases; + +internal sealed class GetQuotesUseCase( + IBrapi brapi +) : IRequestHandler>> +{ + public async Task>> Handle( + GetQuotesQuery input, + CancellationToken cancellationToken) + { + try + { + QuoteResponse? quoteRes = await brapi.GetQuote(input.Ticker); + + List quotes = quoteRes.Results[0].HistoricalDataPrice; + + return quotes.Count switch + { + 0 => Output>.Empty, + _ => Output>.Ok(quotes) + }; + } + catch (Exception ex) + { + return Output>.UnexpectedError([ex.Message]); + } + } +} diff --git a/src/MarketData/Configuration/BrapiHealthCheck.cs b/src/MarketData/Configuration/BrapiHealthCheck.cs index 19efa01..c047e13 100644 --- a/src/MarketData/Configuration/BrapiHealthCheck.cs +++ b/src/MarketData/Configuration/BrapiHealthCheck.cs @@ -1,3 +1,4 @@ +using Kairos.MarketData.Infra; using Kairos.Shared.Configuration; using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -10,24 +11,23 @@ internal sealed class BrapiHealthCheck( IHttpClientFactory clientFactory) : IHealthCheck { readonly ApiOptions _brapi = api.Value.Brapi; - public const string HttpClientName = "brapi-hc"; public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) { try { - var client = clientFactory.CreateClient(HttpClientName); + // TODO: receber interface refit + using var client = clientFactory.CreateClient(typeof(IBrapi).FullName); HttpResponseMessage? res = await client.GetAsync(_brapi.HealthCheckPath, cancellationToken); string result = $"Status Code {res.StatusCode}"; - if (res.IsSuccessStatusCode is false) + return res.IsSuccessStatusCode switch { - return HealthCheckResult.Unhealthy(result); - } - - return HealthCheckResult.Healthy(result); + false => HealthCheckResult.Unhealthy(result), + _ => HealthCheckResult.Healthy(result) + }; } catch (Exception ex) { diff --git a/src/MarketData/DependencyInjection.cs b/src/MarketData/DependencyInjection.cs index ac374e5..4b059c6 100644 --- a/src/MarketData/DependencyInjection.cs +++ b/src/MarketData/DependencyInjection.cs @@ -1,4 +1,6 @@ -using Kairos.MarketData.Configuration; +using System.Reflection; +using System.Text.Json; +using Kairos.MarketData.Configuration; using Kairos.MarketData.Infra; using Kairos.Shared.Configuration; using Kairos.Shared.Infra.HttpClient; @@ -25,14 +27,28 @@ public static IServiceCollection AddMarketData( return services .AddApiClients(api) - .AddHealthCheck(api); + .AddHealthCheck(api) + .AddMediatR(cfg => + { + cfg.LicenseKey = config["Keys:MediatR"]; + cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); + }); } static IServiceCollection AddApiClients(this IServiceCollection services, Settings.Api api) { var brapi = api.Brapi; - services.AddRefitClient() + var refitSettings = new RefitSettings() + { + ContentSerializer = new SystemTextJsonContentSerializer(new JsonSerializerOptions() + { + PropertyNameCaseInsensitive = true, + }) + }; + + services + .AddRefitClient(refitSettings) .ConfigureHttpClient(c => { c.BaseAddress = new Uri(brapi.BaseUrl); @@ -47,15 +63,15 @@ static IServiceCollection AddHealthCheck(this IServiceCollection services, Setti { var brapi = api.Brapi; - services - .AddHttpClient(BrapiHealthCheck.HttpClientName, c => - { - c.BaseAddress = new Uri(brapi.BaseUrl); - c.Timeout = TimeSpan.FromSeconds(brapi.Timeout); - }) - .SetupHttpClient(brapi); + // services + // .AddHttpClient(BrapiHealthCheck.HttpClientName, c => + // { + // c.BaseAddress = new Uri(brapi.BaseUrl); + // c.Timeout = TimeSpan.FromSeconds(brapi.Timeout); + // }) + // .SetupHttpClient(brapi); - services.AddHealthChecks().AddCheck("brapi"); + // services.AddHealthChecks().AddCheck("brapi"); return services; } diff --git a/src/MarketData/Infra/IBrapi.cs b/src/MarketData/Infra/IBrapi.cs index f422da7..5f9975d 100644 --- a/src/MarketData/Infra/IBrapi.cs +++ b/src/MarketData/Infra/IBrapi.cs @@ -1,9 +1,13 @@ +using Kairos.Shared.Contracts.MarketData.GetStockQuotes; using Refit; namespace Kairos.MarketData.Infra; internal interface IBrapi { - [Get("/stocks")] - public Task GetStocks(); + [Get("/quote/{ticker}")] + public Task GetQuote( + string ticker, + [Query] string range = "3mo", + [Query] string interval = "1d"); } \ No newline at end of file diff --git a/src/MarketData/Kairos.MarketData.csproj b/src/MarketData/Kairos.MarketData.csproj index a42b9c7..79550f9 100644 --- a/src/MarketData/Kairos.MarketData.csproj +++ b/src/MarketData/Kairos.MarketData.csproj @@ -4,6 +4,10 @@ + + + + net8.0 enable diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/GetQuotesQuery.cs b/src/Shared/Contracts/MarketData/GetStockQuotes/GetQuotesQuery.cs new file mode 100644 index 0000000..9249dfb --- /dev/null +++ b/src/Shared/Contracts/MarketData/GetStockQuotes/GetQuotesQuery.cs @@ -0,0 +1,13 @@ +using Kairos.Shared.Abstractions; + +namespace Kairos.Shared.Contracts.MarketData.GetStockQuotes; + +public sealed record GetQuotesQuery( + Guid CorrelationId, + string Ticker +) : IQuery> +{ + public GetQuotesQuery(string ticker) : this(Guid.NewGuid(), ticker) + { + } +} \ No newline at end of file diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/QuoteResponse.cs b/src/Shared/Contracts/MarketData/GetStockQuotes/QuoteResponse.cs new file mode 100644 index 0000000..2000fe5 --- /dev/null +++ b/src/Shared/Contracts/MarketData/GetStockQuotes/QuoteResponse.cs @@ -0,0 +1,10 @@ +namespace Kairos.Shared.Contracts.MarketData.GetStockQuotes; + +public sealed partial class QuoteResponse +{ + public required List Results { get; init; } + + public required DateTime RequestedAt { get; init; } + + public required string Took { get; init; } +} \ No newline at end of file diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/StockDetail.cs b/src/Shared/Contracts/MarketData/GetStockQuotes/StockDetail.cs new file mode 100644 index 0000000..2d95294 --- /dev/null +++ b/src/Shared/Contracts/MarketData/GetStockQuotes/StockDetail.cs @@ -0,0 +1,59 @@ +using System.Text.Json.Serialization; + +namespace Kairos.Shared.Contracts.MarketData.GetStockQuotes; + +public sealed class StockDetail +{ + public required string Currency { get; init; } + + public required long MarketCap { get; init; } + + public required string ShortName { get; init; } + + public required string LongName { get; init; } + + public required double RegularMarketChange { get; init; } + + public required double RegularMarketChangePercent { get; init; } + + public required DateTime RegularMarketTime { get; init; } + + public required double RegularMarketPrice { get; init; } + + public required double RegularMarketDayHigh { get; init; } + + public required string RegularMarketDayRange { get; init; } + + public required double RegularMarketDayLow { get; init; } + + public required long RegularMarketVolume { get; init; } + + public required double RegularMarketPreviousClose { get; init; } + + public required double RegularMarketOpen { get; init; } + + public required string FiftyTwoWeekRange { get; init; } + + public required double FiftyTwoWeekLow { get; init; } + + public required double FiftyTwoWeekHigh { get; init; } + + public required string Symbol { get; init; } + + [JsonPropertyName("logourl")] + public required string LogoUrl { get; init; } + + public required string UsedInterval { get; init; } + + public required string UsedRange { get; init; } + + public required List HistoricalDataPrice { get; init; } = new(); + + public required List ValidRanges { get; init; } = new(); + + public required List ValidIntervals { get; init; } = new(); + + public required double PriceEarnings { get; init; } + + public required double EarningsPerShare { get; init; } +} diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/StockQuote.cs b/src/Shared/Contracts/MarketData/GetStockQuotes/StockQuote.cs new file mode 100644 index 0000000..04c87b7 --- /dev/null +++ b/src/Shared/Contracts/MarketData/GetStockQuotes/StockQuote.cs @@ -0,0 +1,21 @@ +namespace Kairos.Shared.Contracts.MarketData.GetStockQuotes; + +public sealed class StockQuote +{ + // Date formatted as Unix Timestamp (e.g., 1756126800) + public required long Date { get; init; } + + public required double Open { get; init; } + + public required double High { get; init; } + + public required double Low { get; init; } + + public required double Close { get; init; } + + public required long Volume { get; init; } + + public required double AdjustedClose { get; init; } + + public DateTime DateAsDateTime => DateTimeOffset.FromUnixTimeSeconds(Date).DateTime; +} \ No newline at end of file diff --git a/src/Shared/Contracts/MarketData/GetAssetsQuery.cs b/src/Shared/Contracts/MarketData/GetStocksQuery.cs similarity index 64% rename from src/Shared/Contracts/MarketData/GetAssetsQuery.cs rename to src/Shared/Contracts/MarketData/GetStocksQuery.cs index ab24ec3..22cefe1 100644 --- a/src/Shared/Contracts/MarketData/GetAssetsQuery.cs +++ b/src/Shared/Contracts/MarketData/GetStocksQuery.cs @@ -2,12 +2,12 @@ namespace Kairos.Shared.Contracts.MarketData; -public sealed record GetAssetsQuery( +public sealed record GetStocksQuery( Guid CorrelationId, IEnumerable SearchTerms ) : IQuery { - public GetAssetsQuery(IEnumerable searchTerms) : this(Guid.NewGuid(), searchTerms) + public GetStocksQuery(IEnumerable searchTerms) : this(Guid.NewGuid(), searchTerms) { } } \ No newline at end of file From c80a06470fe2fc4afeab1120b01c0068f2ae1312 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sun, 23 Nov 2025 12:13:28 -0300 Subject: [PATCH 03/34] Improving brapi.dev health check --- .editorconfig | 3 ++ .gitignore | 4 +- .../Configuration/BrapiHealthCheck.cs | 50 +++++++++++++------ src/MarketData/DependencyInjection.cs | 37 +++++--------- src/Shared/DependencyInjection.cs | 1 - 5 files changed, 53 insertions(+), 42 deletions(-) diff --git a/.editorconfig b/.editorconfig index d0c40ad..d472821 100644 --- a/.editorconfig +++ b/.editorconfig @@ -522,5 +522,8 @@ dotnet_diagnostic.CA2007.severity = none # S1199: Nested code blocks should not be used dotnet_diagnostic.S1199.severity = none +# IDE1006: Naming rule violation +dotnet_diagnostic.IDE1006.severity = none + [**/Migrations/*] generated_code = true \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3f46665..c4937db 100644 --- a/.gitignore +++ b/.gitignore @@ -399,4 +399,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml -.idea \ No newline at end of file +.idea + +.todo \ No newline at end of file diff --git a/src/MarketData/Configuration/BrapiHealthCheck.cs b/src/MarketData/Configuration/BrapiHealthCheck.cs index c047e13..528c352 100644 --- a/src/MarketData/Configuration/BrapiHealthCheck.cs +++ b/src/MarketData/Configuration/BrapiHealthCheck.cs @@ -1,32 +1,52 @@ using Kairos.MarketData.Infra; -using Kairos.Shared.Configuration; -using Microsoft.AspNetCore.WebUtilities; +using Kairos.Shared.Contracts.MarketData.GetStockQuotes; using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.Options; namespace Kairos.MarketData.Configuration; -internal sealed class BrapiHealthCheck( - IOptions api, - IHttpClientFactory clientFactory) : IHealthCheck +internal sealed class BrapiHealthCheck(IBrapi brapi) : IHealthCheck { - readonly ApiOptions _brapi = api.Value.Brapi; + static readonly SemaphoreSlim _lock = new(1, 1); + static readonly TimeSpan _cacheTtl = TimeSpan.FromHours(1); + static HealthCheckResult? _cachedResult; + static DateTimeOffset _cachedAt = DateTimeOffset.MinValue; - public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) { + // Caching mechanism used to avoid making too many requests to brapi.dev + await _lock.WaitAsync(cancellationToken); try { - // TODO: receber interface refit - using var client = clientFactory.CreateClient(typeof(IBrapi).FullName); + if (_cachedResult is not null && (DateTimeOffset.UtcNow - _cachedAt) < _cacheTtl) + { + return (HealthCheckResult)_cachedResult; + } + + _cachedAt = DateTimeOffset.UtcNow; + _cachedResult = await CheckBrapiHealth(); + + return (HealthCheckResult)_cachedResult; + } + finally + { + _lock.Release(); + } + } - HttpResponseMessage? res = await client.GetAsync(_brapi.HealthCheckPath, cancellationToken); + async Task CheckBrapiHealth() + { + try + { + QuoteResponse? res = await brapi.GetQuote("FIQE3", "1d"); - string result = $"Status Code {res.StatusCode}"; + var stock = res.Results[0]; - return res.IsSuccessStatusCode switch + return stock switch { - false => HealthCheckResult.Unhealthy(result), - _ => HealthCheckResult.Healthy(result) + { RegularMarketPrice: > 0 } => HealthCheckResult.Healthy($"Brapi returned {stock.Currency} {stock.RegularMarketPrice} for {stock.Symbol}"), + _ => HealthCheckResult.Unhealthy("Brapi is not returning the expected quote data") }; } catch (Exception ex) diff --git a/src/MarketData/DependencyInjection.cs b/src/MarketData/DependencyInjection.cs index 4b059c6..2047a94 100644 --- a/src/MarketData/DependencyInjection.cs +++ b/src/MarketData/DependencyInjection.cs @@ -44,6 +44,7 @@ static IServiceCollection AddApiClients(this IServiceCollection services, Settin ContentSerializer = new SystemTextJsonContentSerializer(new JsonSerializerOptions() { PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase }) }; @@ -54,38 +55,24 @@ static IServiceCollection AddApiClients(this IServiceCollection services, Settin c.BaseAddress = new Uri(brapi.BaseUrl); c.Timeout = TimeSpan.FromSeconds(brapi.Timeout); }) - .SetupHttpClient(brapi); + .AddHttpMessageHandler(() => new QueryParamHttpHandler("token", brapi.Token)) + .AddTransientHttpErrorPolicy(policyBuilder => + { + IEnumerable jitteredDelays = Backoff.DecorrelatedJitterBackoffV2( + TimeSpan.FromSeconds(brapi.Resilience.MedianFirstRetryDelay), + brapi.Resilience.RetryCount + ); + + return policyBuilder.WaitAndRetryAsync(jitteredDelays); + }); return services; } static IServiceCollection AddHealthCheck(this IServiceCollection services, Settings.Api api) { - var brapi = api.Brapi; - - // services - // .AddHttpClient(BrapiHealthCheck.HttpClientName, c => - // { - // c.BaseAddress = new Uri(brapi.BaseUrl); - // c.Timeout = TimeSpan.FromSeconds(brapi.Timeout); - // }) - // .SetupHttpClient(brapi); - - // services.AddHealthChecks().AddCheck("brapi"); + services.AddHealthChecks().AddCheck("brapi"); return services; } - - static IHttpClientBuilder SetupHttpClient(this IHttpClientBuilder builder, ApiOptions api) => - builder - .AddHttpMessageHandler(() => new QueryParamHttpHandler("token", api.Token)) - .AddTransientHttpErrorPolicy(policyBuilder => - { - IEnumerable jitteredDelays = Backoff.DecorrelatedJitterBackoffV2( - TimeSpan.FromSeconds(api.Resilience.MedianFirstRetryDelay), - api.Resilience.RetryCount - ); - - return policyBuilder.WaitAndRetryAsync(jitteredDelays); - }); } diff --git a/src/Shared/DependencyInjection.cs b/src/Shared/DependencyInjection.cs index 9e09c05..252a6a6 100644 --- a/src/Shared/DependencyInjection.cs +++ b/src/Shared/DependencyInjection.cs @@ -1,5 +1,4 @@ using Azure.Identity; -using HealthChecks.AzureKeyVault; using Kairos.Shared.Infra; using Kairos.Shared.Configuration; using Microsoft.EntityFrameworkCore; From 77866cc64bf11124aa080bdbcd8f897ea920b8c6 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sun, 23 Nov 2025 13:25:10 -0300 Subject: [PATCH 04/34] Fix internal class unit testing --- .editorconfig | 6 ++++++ .github/capp-kairos-seq.yml | 8 ++++---- README.md | 6 ++---- src/Account/Kairos.Account.csproj | 1 + src/MarketData/Infra/IBrapi.cs | 3 +++ 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.editorconfig b/.editorconfig index d472821..64b3b07 100644 --- a/.editorconfig +++ b/.editorconfig @@ -525,5 +525,11 @@ dotnet_diagnostic.S1199.severity = none # IDE1006: Naming rule violation dotnet_diagnostic.IDE1006.severity = none +# S2139: Either log this exception and handle it, or rethrow it with some contextual information +dotnet_diagnostic.S2139.severity = none + +# CA1707: Remove the underscores from member name +dotnet_diagnostic.CA1707.severity = none + [**/Migrations/*] generated_code = true \ No newline at end of file diff --git a/.github/capp-kairos-seq.yml b/.github/capp-kairos-seq.yml index 2a0d5d1..bac8930 100644 --- a/.github/capp-kairos-seq.yml +++ b/.github/capp-kairos-seq.yml @@ -20,8 +20,8 @@ properties: external: true secrets: - - name: seq-password - keyVaultUrl: https://kv-kairos.vault.azure.net/secrets/seq-password + - name: seq-password-hash + keyVaultUrl: https://kv-kairos.vault.azure.net/secrets/seq-password-hash identity: "/subscriptions/28b743cd-805d-4451-ba1f-067df11cbafc/resourcegroups/kairos/providers/Microsoft.ManagedIdentity/userAssignedIdentities/kairos-service" template: @@ -34,8 +34,8 @@ properties: env: - name: ACCEPT_EULA value: Y - - name: SEQ_FIRSTRUN_ADMINPASSWORD - secretRef: seq-password + - name: SEQ_FIRSTRUN_ADMINPASSWORDHASH + secretRef: seq-password-hash volumeMounts: - volumeName: seq-data diff --git a/README.md b/README.md index 77fba37..77fdcaa 100644 --- a/README.md +++ b/README.md @@ -12,14 +12,12 @@ 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). -High Level Architecture - -# Azure resources - - 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/ +High Level Architecture + # How to run it locally To run the app outside of a docker container, log in to Azure, in order to get access to the Azure KV secrets in a passwordless manner: diff --git a/src/Account/Kairos.Account.csproj b/src/Account/Kairos.Account.csproj index 3d283d1..4ac9bf6 100644 --- a/src/Account/Kairos.Account.csproj +++ b/src/Account/Kairos.Account.csproj @@ -7,5 +7,6 @@ + \ No newline at end of file diff --git a/src/MarketData/Infra/IBrapi.cs b/src/MarketData/Infra/IBrapi.cs index 5f9975d..6a23c05 100644 --- a/src/MarketData/Infra/IBrapi.cs +++ b/src/MarketData/Infra/IBrapi.cs @@ -3,6 +3,9 @@ namespace Kairos.MarketData.Infra; +/// +/// Open API reference for brapi.dev: https://brapi.dev/swagger/latest.json +/// internal interface IBrapi { [Get("/quote/{ticker}")] From 04347046b9ba8fa4c44680463a8ec0fa50b902c8 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sun, 23 Nov 2025 15:21:40 -0300 Subject: [PATCH 05/34] Fix seq and rabbitmq ACA volume mounting --- .github/capp-kairos-rabbitmq.yml | 8 ++--- .github/capp-kairos-seq.yml | 8 ++--- README.md | 49 +++++++++++++++----------- src/Gateway/appsettings.Local.json | 2 +- src/Gateway/appsettings.json | 3 +- src/Shared/Configuration/ApiOptions.cs | 2 -- 6 files changed, 39 insertions(+), 33 deletions(-) diff --git a/.github/capp-kairos-rabbitmq.yml b/.github/capp-kairos-rabbitmq.yml index 7888f5b..f0c292c 100644 --- a/.github/capp-kairos-rabbitmq.yml +++ b/.github/capp-kairos-rabbitmq.yml @@ -29,8 +29,8 @@ properties: - image: docker.io/masstransit/rabbitmq:latest name: kairos-rabbitmq resources: - cpu: 1 - memory: 2Gi + cpu: 2 + memory: 4Gi env: - name: RABBITMQ_DEFAULT_USER value: dev @@ -44,8 +44,8 @@ properties: volumes: - name: rabbitmq-data storageType: AzureFile - storageName: rabbitmq-data + storageName: fs-kairos-rabbitmq scale: minReplicas: 0 - maxReplicas: 5 \ No newline at end of file + maxReplicas: 1 \ No newline at end of file diff --git a/.github/capp-kairos-seq.yml b/.github/capp-kairos-seq.yml index bac8930..ec81d1f 100644 --- a/.github/capp-kairos-seq.yml +++ b/.github/capp-kairos-seq.yml @@ -29,8 +29,8 @@ properties: - image: docker.io/datalust/seq:2025.2 name: kairos-seq resources: - cpu: 1 - memory: 2Gi + cpu: 2 + memory: 4Gi env: - name: ACCEPT_EULA value: Y @@ -44,8 +44,8 @@ properties: volumes: - name: seq-data storageType: AzureFile - storageName: seq-data + storageName: fs-kairos-seq scale: minReplicas: 0 - maxReplicas: 5 \ No newline at end of file + maxReplicas: 1 \ No newline at end of file diff --git a/README.md b/README.md index 77fdcaa..e486c02 100644 --- a/README.md +++ b/README.md @@ -77,9 +77,9 @@ az containerapp env create \ --infrastructure-subnet-resource-id $SUBNET_ID ``` -## RabbitMQ +## Storage Account -Before creating the RabbitMQ container app, it's required to create an Azure File Share that'll be used for mounting the volume, in order to persist the Rabbit data. +Create a storage account that'll be used by the stateful containers (e.g., Seq and RabbitMQ): ```sh STORAGE_ACCOUNT="kairostoraging" @@ -89,28 +89,38 @@ az storage account create \ --name $STORAGE_ACCOUNT \ --resource-group kairos \ --location eastus2 \ - --sku Standard_LRS + --enable-large-file-share \ + --sku Standard_LRS \ + --query provisioningState # Get the Storage Account Key STORAGE_KEY=$(az storage account keys list -g kairos -n $STORAGE_ACCOUNT --query "[0].value" -o tsv) +``` + +## RabbitMQ + +Before creating the RabbitMQ container app, it's required to create an Azure File Share that'll be used for mounting the volume, in order to persist the Rabbit data. +```sh # Create the File Share FILE_SHARE="fs-kairos-rabbitmq" -az storage share create \ +az storage share-rm create \ + --resource-group kairos \ --name $FILE_SHARE \ - --account-name $STORAGE_ACCOUNT \ - --account-key $STORAGE_KEY + --storage-account $STORAGE_ACCOUNT \ + --output table # Link ACA env to the storage az containerapp env storage set \ --name cae-kairos \ --resource-group kairos \ - --storage-name rabbitmq-data \ + --storage-name $FILE_SHARE \ --azure-file-account-name $STORAGE_ACCOUNT \ --azure-file-account-key $STORAGE_KEY \ --azure-file-share-name $FILE_SHARE \ - --access-mode ReadWrite + --access-mode ReadWrite \ + --output table ``` Now the ACA can be created: @@ -120,7 +130,8 @@ az containerapp create \ --name capp-kairos-rabbitmq \ --resource-group kairos \ --environment cae-kairos \ - --yaml .github/capp-kairos-rabbitmq.yml + --yaml .github/capp-kairos-rabbitmq.yml \ + --output table ``` ## Seq @@ -128,28 +139,25 @@ az containerapp create \ Assuming that the storage account was already created because of RabbitMQ, then the seq infra creation would be the following: ```sh -STORAGE_ACCOUNT="kairostoraging" - -# Get the Storage Account Key -STORAGE_KEY=$(az storage account keys list -g kairos -n $STORAGE_ACCOUNT --query "[0].value" -o tsv) - # Create the File Share FILE_SHARE="fs-kairos-seq" -az storage share create \ +az storage share-rm create \ + --resource-group kairos \ --name $FILE_SHARE \ - --account-name $STORAGE_ACCOUNT \ - --account-key $STORAGE_KEY + --storage-account $STORAGE_ACCOUNT \ + --output table # Link ACA env to the storage az containerapp env storage set \ --name cae-kairos \ --resource-group kairos \ - --storage-name seq-data \ + --storage-name $FILE_SHARE \ --azure-file-account-name $STORAGE_ACCOUNT \ --azure-file-account-key $STORAGE_KEY \ --azure-file-share-name $FILE_SHARE \ - --access-mode ReadWrite + --access-mode ReadWrite \ + --output table ``` Now the ACA can be created: @@ -159,7 +167,8 @@ az containerapp create \ --name capp-kairos-seq \ --resource-group kairos \ --environment cae-kairos \ - --yaml .github/capp-kairos-seq.yml + --yaml .github/capp-kairos-seq.yml \ + --output table ``` ## Kairos Broker diff --git a/src/Gateway/appsettings.Local.json b/src/Gateway/appsettings.Local.json index 7074222..a382356 100644 --- a/src/Gateway/appsettings.Local.json +++ b/src/Gateway/appsettings.Local.json @@ -14,7 +14,7 @@ }, "Health": { "Seq": { "Url": "https://capp-kairos-seq.salmonpebble-5905d3a4.eastus2.azurecontainerapps.io:5341/health" } }, "EventBus": { - "HostAddress": "rabbitmq://dev:dev@localhost:5672", + "HostAddress": "rabbitmq://dev:dev@localhost:5672/kairos", "Events": { "AccountOpened": { "VirtualHost": "account", diff --git a/src/Gateway/appsettings.json b/src/Gateway/appsettings.json index f42e4cc..e26bc15 100644 --- a/src/Gateway/appsettings.json +++ b/src/Gateway/appsettings.json @@ -12,8 +12,7 @@ "Resilience": { "MedianFirstRetryDelay": 1, "RetryCount": 3 - }, - "HealthCheckPath": "/quote/FIQE3" + } } }, "Database": { diff --git a/src/Shared/Configuration/ApiOptions.cs b/src/Shared/Configuration/ApiOptions.cs index 14316f3..b807a16 100644 --- a/src/Shared/Configuration/ApiOptions.cs +++ b/src/Shared/Configuration/ApiOptions.cs @@ -18,6 +18,4 @@ public sealed class ApiOptions public required int Timeout { get; init; } public required ResilienceOptions Resilience { get; init; } - - public string HealthCheckPath { get; init; } = string.Empty; } \ No newline at end of file From 97451769215c3113cdc8dc00617393329ff10dc2 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sun, 23 Nov 2025 23:03:45 -0300 Subject: [PATCH 06/34] Streaming and formatting the brapi quotes --- .github/capp-kairos-broker.yml | 4 +- .github/capp-kairos-seq.yml | 13 ++++- src/Gateway/DependencyInjection.cs | 26 +++++---- src/Gateway/Modules/MarketDataModule.cs | 9 ++- .../Business/UseCases/GetQuotesUseCase.cs | 57 ++++++++++++++++--- .../Configuration/BrapiHealthCheck.cs | 2 +- .../Infra/Dtos}/QuoteResponse.cs | 2 +- .../Infra/Dtos}/StockDetail.cs | 2 +- .../Infra/Dtos}/StockQuote.cs | 4 +- src/MarketData/Infra/IBrapi.cs | 2 +- src/MarketData/Kairos.MarketData.csproj | 4 -- .../GetStockQuotes/GetQuotesQuery.cs | 10 ++-- .../MarketData/GetStockQuotes/Quote.cs | 14 +++++ .../MarketData/GetStockQuotes/QuoteRange.cs | 42 ++++++++++++++ .../{Enums => Contracts}/OutputStatus.cs | 0 src/Shared/Extensions/EnumExtensions.cs | 24 ++++++++ 16 files changed, 171 insertions(+), 44 deletions(-) rename src/{Shared/Contracts/MarketData/GetStockQuotes => MarketData/Infra/Dtos}/QuoteResponse.cs (75%) rename src/{Shared/Contracts/MarketData/GetStockQuotes => MarketData/Infra/Dtos}/StockDetail.cs (93%) rename src/{Shared/Contracts/MarketData/GetStockQuotes => MarketData/Infra/Dtos}/StockQuote.cs (71%) create mode 100644 src/Shared/Contracts/MarketData/GetStockQuotes/Quote.cs create mode 100644 src/Shared/Contracts/MarketData/GetStockQuotes/QuoteRange.cs rename src/Shared/{Enums => Contracts}/OutputStatus.cs (100%) create mode 100644 src/Shared/Extensions/EnumExtensions.cs diff --git a/.github/capp-kairos-broker.yml b/.github/capp-kairos-broker.yml index fef5208..214826d 100644 --- a/.github/capp-kairos-broker.yml +++ b/.github/capp-kairos-broker.yml @@ -24,8 +24,8 @@ properties: - image: kairosfinance.azurecr.io/kairos/broker name: kairos-broker resources: - cpu: 1 - memory: 2Gi + cpu: 2 + memory: 4Gi probes: - type: Liveness httpGet: diff --git a/.github/capp-kairos-seq.yml b/.github/capp-kairos-seq.yml index ec81d1f..b0438d0 100644 --- a/.github/capp-kairos-seq.yml +++ b/.github/capp-kairos-seq.yml @@ -36,10 +36,17 @@ properties: value: Y - name: SEQ_FIRSTRUN_ADMINPASSWORDHASH secretRef: seq-password-hash + probes: + - type: Liveness + httpGet: + path: "/health" + port: 80 + initialDelaySeconds: 30 + periodSeconds: 30 - volumeMounts: - - volumeName: seq-data - mountPath: /data + # volumeMounts: + # - volumeName: seq-data + # mountPath: /data volumes: - name: seq-data diff --git a/src/Gateway/DependencyInjection.cs b/src/Gateway/DependencyInjection.cs index 2ce9a4b..42cd347 100644 --- a/src/Gateway/DependencyInjection.cs +++ b/src/Gateway/DependencyInjection.cs @@ -1,7 +1,9 @@ +using System.Text.Json.Serialization; using Carter; using Kairos.Account; using Kairos.Shared.Configuration; using MassTransit; +using Microsoft.AspNetCore.Http.Json; using Microsoft.Extensions.Options; using Microsoft.OpenApi.Models; @@ -57,16 +59,18 @@ static IServiceCollection AddEventBus(this IServiceCollection services, IConfigu } static IServiceCollection AddSwagger(this IServiceCollection services) => - // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle - services.AddSwaggerGen(o => o.SwaggerDoc("v1", new OpenApiInfo - { - Version = "v1", - Title = "Kairos", - Description = "Kairos Brokerage back-end services", - Contact = new OpenApiContact + services + .Configure(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter())) + // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle + .AddSwaggerGen(o => o.SwaggerDoc("v1", new OpenApiInfo { - Name = "Kairos Dev Team", - Email = "kairos.fintech@gmail.com", - }, - })); + Version = "v1", + Title = "Kairos", + Description = "Kairos Brokerage back-end services", + Contact = new OpenApiContact + { + Name = "Kairos Dev Team", + Email = "kairos.fintech@gmail.com", + }, + })); } diff --git a/src/Gateway/Modules/MarketDataModule.cs b/src/Gateway/Modules/MarketDataModule.cs index 9b297fb..6d99d28 100644 --- a/src/Gateway/Modules/MarketDataModule.cs +++ b/src/Gateway/Modules/MarketDataModule.cs @@ -34,9 +34,12 @@ public override void AddRoutes(IEndpointRouteBuilder app) app.MapGet( "/stocks/{ticker}/quote", - async (HttpContext http, IMediator mediator, [FromRoute] string ticker) => + async ( + IMediator mediator, + [FromRoute] string ticker, + [FromQuery] QuoteRange range = QuoteRange.FiveDays) => { - var res = await mediator.Send(new GetQuotesQuery(ticker)); + var res = await mediator.Send(new GetQuotesQuery(ticker, range)); if (res.IsFailure) { @@ -47,6 +50,6 @@ public override void AddRoutes(IEndpointRouteBuilder app) return Results.Ok(res); }) - .WithDescription("Get a stock's historical prices"); + .WithDescription("Get a stock's historical quotes"); } } \ No newline at end of file diff --git a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs index f39a094..dc489ad 100644 --- a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs +++ b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs @@ -1,33 +1,72 @@ using Kairos.MarketData.Infra; -using Kairos.Shared.Contracts; +using Kairos.MarketData.Infra.Dtos; using Kairos.Shared.Contracts.MarketData.GetStockQuotes; +using Kairos.Shared.Extensions; using MediatR; +using Output = Kairos.Shared.Contracts.Output>; namespace Kairos.MarketData.Business.UseCases; -internal sealed class GetQuotesUseCase( - IBrapi brapi -) : IRequestHandler>> +internal sealed class GetQuotesUseCase(IBrapi brapi) + : IRequestHandler { - public async Task>> Handle( + static readonly string[] _testTickers = [ "PETR4", "MGLU3", "VALE3", "ITUB4" ]; + static readonly QuoteRange[] _freeRanges = [ + QuoteRange.Day, + QuoteRange.FiveDays, + QuoteRange.Month, + QuoteRange.Quarter + ]; + + public async Task Handle( GetQuotesQuery input, CancellationToken cancellationToken) { try { - QuoteResponse? quoteRes = await brapi.GetQuote(input.Ticker); + QuoteResponse? quoteRes = await brapi.GetQuote( + input.Ticker, + GetValidRange(input).GetDescription()); List quotes = quoteRes.Results[0].HistoricalDataPrice; return quotes.Count switch { - 0 => Output>.Empty, - _ => Output>.Ok(quotes) + 0 => Output.Empty, + _ => Output.Ok(FormatQuotes(quotes)) }; } catch (Exception ex) { - return Output>.UnexpectedError([ex.Message]); + return Output.UnexpectedError([ex.Message]); + } + } + + static async IAsyncEnumerable FormatQuotes(IEnumerable quotes) + { + foreach (var quote in quotes) + { + await Task.Delay(50); + + yield return new Quote( + quote.Date, + quote.Close, + quote.AdjustedClose); + } + } + + static QuoteRange GetValidRange(GetQuotesQuery input) + { + if (_freeRanges.Contains(input.Range)) + { + return input.Range; } + + if (_testTickers.Contains(input.Ticker)) + { + return input.Range; + } + + return QuoteRange.Quarter; } } diff --git a/src/MarketData/Configuration/BrapiHealthCheck.cs b/src/MarketData/Configuration/BrapiHealthCheck.cs index 528c352..82c0505 100644 --- a/src/MarketData/Configuration/BrapiHealthCheck.cs +++ b/src/MarketData/Configuration/BrapiHealthCheck.cs @@ -1,5 +1,5 @@ using Kairos.MarketData.Infra; -using Kairos.Shared.Contracts.MarketData.GetStockQuotes; +using Kairos.MarketData.Infra.Dtos; using Microsoft.Extensions.Diagnostics.HealthChecks; namespace Kairos.MarketData.Configuration; diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/QuoteResponse.cs b/src/MarketData/Infra/Dtos/QuoteResponse.cs similarity index 75% rename from src/Shared/Contracts/MarketData/GetStockQuotes/QuoteResponse.cs rename to src/MarketData/Infra/Dtos/QuoteResponse.cs index 2000fe5..ff4e844 100644 --- a/src/Shared/Contracts/MarketData/GetStockQuotes/QuoteResponse.cs +++ b/src/MarketData/Infra/Dtos/QuoteResponse.cs @@ -1,4 +1,4 @@ -namespace Kairos.Shared.Contracts.MarketData.GetStockQuotes; +namespace Kairos.MarketData.Infra.Dtos; public sealed partial class QuoteResponse { diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/StockDetail.cs b/src/MarketData/Infra/Dtos/StockDetail.cs similarity index 93% rename from src/Shared/Contracts/MarketData/GetStockQuotes/StockDetail.cs rename to src/MarketData/Infra/Dtos/StockDetail.cs index 2d95294..3ff202e 100644 --- a/src/Shared/Contracts/MarketData/GetStockQuotes/StockDetail.cs +++ b/src/MarketData/Infra/Dtos/StockDetail.cs @@ -1,6 +1,6 @@ using System.Text.Json.Serialization; -namespace Kairos.Shared.Contracts.MarketData.GetStockQuotes; +namespace Kairos.MarketData.Infra.Dtos; public sealed class StockDetail { diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/StockQuote.cs b/src/MarketData/Infra/Dtos/StockQuote.cs similarity index 71% rename from src/Shared/Contracts/MarketData/GetStockQuotes/StockQuote.cs rename to src/MarketData/Infra/Dtos/StockQuote.cs index 04c87b7..bdc7e05 100644 --- a/src/Shared/Contracts/MarketData/GetStockQuotes/StockQuote.cs +++ b/src/MarketData/Infra/Dtos/StockQuote.cs @@ -1,4 +1,4 @@ -namespace Kairos.Shared.Contracts.MarketData.GetStockQuotes; +namespace Kairos.MarketData.Infra.Dtos; public sealed class StockQuote { @@ -16,6 +16,4 @@ public sealed class StockQuote public required long Volume { get; init; } public required double AdjustedClose { get; init; } - - public DateTime DateAsDateTime => DateTimeOffset.FromUnixTimeSeconds(Date).DateTime; } \ No newline at end of file diff --git a/src/MarketData/Infra/IBrapi.cs b/src/MarketData/Infra/IBrapi.cs index 6a23c05..e479501 100644 --- a/src/MarketData/Infra/IBrapi.cs +++ b/src/MarketData/Infra/IBrapi.cs @@ -1,4 +1,4 @@ -using Kairos.Shared.Contracts.MarketData.GetStockQuotes; +using Kairos.MarketData.Infra.Dtos; using Refit; namespace Kairos.MarketData.Infra; diff --git a/src/MarketData/Kairos.MarketData.csproj b/src/MarketData/Kairos.MarketData.csproj index 79550f9..a42b9c7 100644 --- a/src/MarketData/Kairos.MarketData.csproj +++ b/src/MarketData/Kairos.MarketData.csproj @@ -4,10 +4,6 @@ - - - - net8.0 enable diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/GetQuotesQuery.cs b/src/Shared/Contracts/MarketData/GetStockQuotes/GetQuotesQuery.cs index 9249dfb..47041a7 100644 --- a/src/Shared/Contracts/MarketData/GetStockQuotes/GetQuotesQuery.cs +++ b/src/Shared/Contracts/MarketData/GetStockQuotes/GetQuotesQuery.cs @@ -4,10 +4,10 @@ namespace Kairos.Shared.Contracts.MarketData.GetStockQuotes; public sealed record GetQuotesQuery( Guid CorrelationId, - string Ticker -) : IQuery> + string Ticker, + QuoteRange Range +) : IQuery> { - public GetQuotesQuery(string ticker) : this(Guid.NewGuid(), ticker) - { - } + public GetQuotesQuery(string ticker, QuoteRange range) + : this(Guid.NewGuid(), ticker, range) { } } \ No newline at end of file diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/Quote.cs b/src/Shared/Contracts/MarketData/GetStockQuotes/Quote.cs new file mode 100644 index 0000000..1073461 --- /dev/null +++ b/src/Shared/Contracts/MarketData/GetStockQuotes/Quote.cs @@ -0,0 +1,14 @@ +namespace Kairos.Shared.Contracts.MarketData.GetStockQuotes; + +public sealed record Quote( + DateTime Date, + double Close, + double CloseWithEvents +) +{ + public Quote(long unixTimeSeconds, double close, double adjustedClose) : this( + DateTimeOffset.FromUnixTimeSeconds(unixTimeSeconds).DateTime, + close, + adjustedClose + ) { } +} \ No newline at end of file diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/QuoteRange.cs b/src/Shared/Contracts/MarketData/GetStockQuotes/QuoteRange.cs new file mode 100644 index 0000000..b4299f8 --- /dev/null +++ b/src/Shared/Contracts/MarketData/GetStockQuotes/QuoteRange.cs @@ -0,0 +1,42 @@ +using System.ComponentModel; + +namespace Kairos.Shared.Contracts.MarketData.GetStockQuotes; + +public enum QuoteRange +{ + [Description("1d")] + Day, + + [Description("5d")] + FiveDays, + + [Description("7d")] + Week, + + [Description("1mo")] + Month, + + [Description("3mo")] + Quarter, + + [Description("6mo")] + Semester, + + [Description("1y")] + Year, + + [Description("2y")] + TwoYears, + + [Description("5y")] + FiveYears, + + [Description("10y")] + Decade, + + [Description("ytd")] + YearToDate, + + [Description("max")] + Max +} diff --git a/src/Shared/Enums/OutputStatus.cs b/src/Shared/Contracts/OutputStatus.cs similarity index 100% rename from src/Shared/Enums/OutputStatus.cs rename to src/Shared/Contracts/OutputStatus.cs diff --git a/src/Shared/Extensions/EnumExtensions.cs b/src/Shared/Extensions/EnumExtensions.cs new file mode 100644 index 0000000..7188417 --- /dev/null +++ b/src/Shared/Extensions/EnumExtensions.cs @@ -0,0 +1,24 @@ +using System; +using System.ComponentModel; +using System.Reflection; + +namespace Kairos.Shared.Extensions; + +public static class EnumExtensions +{ + public static string GetDescription(this Enum value) + { + FieldInfo? field = value.GetType().GetField(value.ToString()); + + if (field != null) + { + if (Attribute.GetCustomAttribute(field, typeof(DescriptionAttribute)) + is DescriptionAttribute attribute) + { + return attribute.Description; + } + } + + return value.ToString(); + } +} From e303e489460fb55f29d7d98a4987e47b1e09b878 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Thu, 27 Nov 2025 02:30:32 -0300 Subject: [PATCH 07/34] Global response formatter --- Directory.Packages.props | 1 + src/Gateway/DependencyInjection.cs | 15 +++++++ src/Gateway/Filters/ResponseFormatter.cs | 42 ++++++++++++++++++ src/Gateway/Kairos.Gateway.csproj | 1 + src/Gateway/Modules/AccountModule.cs | 22 +++------- src/Gateway/Modules/MarketDataModule.cs | 44 ++++++------------- src/Gateway/Program.cs | 3 ++ .../GetStockQuotes/GetQuotesQuery.cs | 8 +++- 8 files changed, 88 insertions(+), 48 deletions(-) create mode 100644 src/Gateway/Filters/ResponseFormatter.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 954d598..62e40d1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,6 +14,7 @@ + diff --git a/src/Gateway/DependencyInjection.cs b/src/Gateway/DependencyInjection.cs index 42cd347..9f0a993 100644 --- a/src/Gateway/DependencyInjection.cs +++ b/src/Gateway/DependencyInjection.cs @@ -1,7 +1,10 @@ using System.Text.Json.Serialization; using Carter; using Kairos.Account; +using Kairos.Gateway.Filters; using Kairos.Shared.Configuration; +using Kairos.Shared.Contracts; +using Mapster; using MassTransit; using Microsoft.AspNetCore.Http.Json; using Microsoft.Extensions.Options; @@ -26,12 +29,24 @@ public static IServiceCollection AddGateway( services.AddCarter(); return services + .AddMapper() .AddEventBus(configuration) .AddEndpointsApiExplorer() .AddSwagger() .AddResponseCompression(); } + static IServiceCollection AddMapper(this IServiceCollection services) + { + TypeAdapterConfig + .GlobalSettings + .ForType(typeof(Output<>), typeof(Filters.Response<>)) + .Map("Data", "Value"); + + return services + .AddSingleton(TypeAdapterConfig.GlobalSettings); + } + static IServiceCollection AddEventBus(this IServiceCollection services, IConfiguration configuration) { services.Configure(configuration.GetSection("EventBus")); diff --git a/src/Gateway/Filters/ResponseFormatter.cs b/src/Gateway/Filters/ResponseFormatter.cs new file mode 100644 index 0000000..2df4b0d --- /dev/null +++ b/src/Gateway/Filters/ResponseFormatter.cs @@ -0,0 +1,42 @@ +using Kairos.Shared.Contracts; +using Kairos.Shared.Enums; +using Mapster; + +namespace Kairos.Gateway.Filters; + +internal sealed record Response(T? Data, string[] Messages); + +internal sealed class ResponseFormatter : IEndpointFilter +{ + public async ValueTask InvokeAsync( + EndpointFilterInvocationContext context, + EndpointFilterDelegate next) + { + var response = await next(context); + + if (response is not Output output) + { + return response; + } + + var statusCode = output.Status switch + { + OutputStatus.Ok => StatusCodes.Status200OK, + OutputStatus.Created => StatusCodes.Status201Created, + OutputStatus.Empty => StatusCodes.Status204NoContent, + OutputStatus.InvalidInput => StatusCodes.Status400BadRequest, + OutputStatus.UnexistentId => StatusCodes.Status404NotFound, + OutputStatus.BusinessLogicViolation => StatusCodes.Status422UnprocessableEntity, + _ => StatusCodes.Status500InternalServerError, + }; + + if (statusCode == StatusCodes.Status204NoContent) + { + return Results.NoContent(); + } + + var res = output.Adapt>(); + + return Results.Json(res, statusCode: statusCode); + } +} diff --git a/src/Gateway/Kairos.Gateway.csproj b/src/Gateway/Kairos.Gateway.csproj index c7fa344..b150373 100644 --- a/src/Gateway/Kairos.Gateway.csproj +++ b/src/Gateway/Kairos.Gateway.csproj @@ -9,6 +9,7 @@ + diff --git a/src/Gateway/Modules/AccountModule.cs b/src/Gateway/Modules/AccountModule.cs index 2c79662..d1fa2d0 100644 --- a/src/Gateway/Modules/AccountModule.cs +++ b/src/Gateway/Modules/AccountModule.cs @@ -8,29 +8,21 @@ namespace Kairos.Gateway.Modules; public sealed class AccountModule : CarterModule { - public AccountModule() : base("/api/v1/account") + readonly IMediator _mediator; + + public AccountModule(IMediator mediator) : base("/api/v1/account") { WithTags("Account"); + + _mediator = mediator; } public override void AddRoutes(IEndpointRouteBuilder app) { app .MapPost( - "/open", - async (IMediator mediator, [FromBody] OpenAccount command) => - { - Output? res = await mediator.Send(command); - - if (res.IsFailure) - { - return Results.Json( - res, - statusCode: StatusCodes.Status500InternalServerError); - } - - return Results.Ok(res); - }) + "/open", + ([FromBody] OpenAccount command) => _mediator.Send(command)) .WithDescription("Open an investment account"); } } \ No newline at end of file diff --git a/src/Gateway/Modules/MarketDataModule.cs b/src/Gateway/Modules/MarketDataModule.cs index 6d99d28..d95109f 100644 --- a/src/Gateway/Modules/MarketDataModule.cs +++ b/src/Gateway/Modules/MarketDataModule.cs @@ -1,5 +1,4 @@ using Carter; -using Kairos.Shared.Contracts; using Kairos.Shared.Contracts.MarketData; using Kairos.Shared.Contracts.MarketData.GetStockQuotes; using MediatR; @@ -9,47 +8,30 @@ namespace Kairos.Gateway.Modules; public sealed class MarketDataModule : CarterModule { - public MarketDataModule() : base("/api/v1/market-data") + readonly IMediator _mediator; + + public MarketDataModule(IMediator mediator) : base("/api/v1/market-data") { WithTags("MarketData"); + + _mediator = mediator; } public override void AddRoutes(IEndpointRouteBuilder app) { app - .MapGet("/stocks", async (IMediator mediator, [FromQuery] string[] search) => - { - Output? res = await mediator.Send(new GetStocksQuery(search)); - - if (res.IsFailure) - { - return Results.Json( - res, - statusCode: StatusCodes.Status500InternalServerError); - } - - return Results.Ok(res); - }) + .MapGet( + "/stocks", + ([FromQuery] string[] search) => _mediator.Send(new GetStocksQuery(search))) .WithDescription("Get basic information about the specified stock(s)"); app.MapGet( - "/stocks/{ticker}/quote", - async ( - IMediator mediator, + "/stocks/{ticker}/quote", + ( + IMediator mediator, [FromRoute] string ticker, - [FromQuery] QuoteRange range = QuoteRange.FiveDays) => - { - var res = await mediator.Send(new GetQuotesQuery(ticker, range)); - - if (res.IsFailure) - { - return Results.Json( - res, - statusCode: StatusCodes.Status500InternalServerError); - } - - return Results.Ok(res); - }) + [FromQuery] QuoteRange? range = null) => + _mediator.Send(new GetQuotesQuery(ticker, range))) .WithDescription("Get a stock's historical quotes"); } } \ No newline at end of file diff --git a/src/Gateway/Program.cs b/src/Gateway/Program.cs index 290b925..54b4103 100644 --- a/src/Gateway/Program.cs +++ b/src/Gateway/Program.cs @@ -2,6 +2,7 @@ using HealthChecks.UI.Client; using Kairos.Account; using Kairos.Gateway; +using Kairos.Gateway.Filters; using Kairos.MarketData; using Kairos.Shared; using Microsoft.AspNetCore.Diagnostics.HealthChecks; @@ -55,6 +56,8 @@ }); app + .MapGroup(string.Empty) + .AddEndpointFilter() .MapCarter() .MapHealthChecksUI(o => { diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/GetQuotesQuery.cs b/src/Shared/Contracts/MarketData/GetStockQuotes/GetQuotesQuery.cs index 47041a7..1984b49 100644 --- a/src/Shared/Contracts/MarketData/GetStockQuotes/GetQuotesQuery.cs +++ b/src/Shared/Contracts/MarketData/GetStockQuotes/GetQuotesQuery.cs @@ -1,3 +1,4 @@ +using System.Globalization; using Kairos.Shared.Abstractions; namespace Kairos.Shared.Contracts.MarketData.GetStockQuotes; @@ -8,6 +9,9 @@ public sealed record GetQuotesQuery( QuoteRange Range ) : IQuery> { - public GetQuotesQuery(string ticker, QuoteRange range) - : this(Guid.NewGuid(), ticker, range) { } + public GetQuotesQuery(string ticker, QuoteRange? range = null) + : this( + Guid.NewGuid(), + ticker.ToUpper(CultureInfo.InvariantCulture), + range ?? QuoteRange.FiveDays) { } } \ No newline at end of file From 1c30b834093199bc23b8a33723a394a789adf4ea Mon Sep 17 00:00:00 2001 From: sagustavo Date: Thu, 27 Nov 2025 03:04:50 -0300 Subject: [PATCH 08/34] Global error handler --- src/Gateway/DependencyInjection.cs | 44 ++++++++++--- src/Gateway/Filters/ResponseFormatter.cs | 62 ++++++++++++------- .../Business/UseCases/GetQuotesUseCase.cs | 6 +- src/MarketData/Infra/Dtos/QuoteResponse.cs | 2 +- 4 files changed, 78 insertions(+), 36 deletions(-) diff --git a/src/Gateway/DependencyInjection.cs b/src/Gateway/DependencyInjection.cs index 9f0a993..27632f1 100644 --- a/src/Gateway/DependencyInjection.cs +++ b/src/Gateway/DependencyInjection.cs @@ -8,7 +8,9 @@ using MassTransit; using Microsoft.AspNetCore.Http.Json; using Microsoft.Extensions.Options; +using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; namespace Kairos.Gateway; @@ -77,15 +79,39 @@ static IServiceCollection AddSwagger(this IServiceCollection services) => services .Configure(o => o.SerializerOptions.Converters.Add(new JsonStringEnumConverter())) // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle - .AddSwaggerGen(o => o.SwaggerDoc("v1", new OpenApiInfo + .AddSwaggerGen(o => { - Version = "v1", - Title = "Kairos", - Description = "Kairos Brokerage back-end services", - Contact = new OpenApiContact + o.SchemaFilter(); + + o.SwaggerDoc("v1", new OpenApiInfo + { + Version = "v1", + Title = "Kairos", + Description = "Kairos Brokerage back-end services", + Contact = new OpenApiContact + { + Name = "Kairos Dev Team", + Email = "kairos.fintech@gmail.com", + }, + }); + }); + + sealed class EnumSchemaFilter : ISchemaFilter + { + public void Apply(OpenApiSchema schema, SchemaFilterContext context) { - Name = "Kairos Dev Team", - Email = "kairos.fintech@gmail.com", - }, - })); + if (context.Type.IsEnum) + { + schema.Enum.Clear(); + + schema.Type = "string"; + schema.Format = null; + + foreach (var name in Enum.GetNames(context.Type)) + { + schema.Enum.Add(new OpenApiString(name)); + } + } + } + } } diff --git a/src/Gateway/Filters/ResponseFormatter.cs b/src/Gateway/Filters/ResponseFormatter.cs index 2df4b0d..96ac8b4 100644 --- a/src/Gateway/Filters/ResponseFormatter.cs +++ b/src/Gateway/Filters/ResponseFormatter.cs @@ -6,37 +6,53 @@ namespace Kairos.Gateway.Filters; internal sealed record Response(T? Data, string[] Messages); -internal sealed class ResponseFormatter : IEndpointFilter +internal sealed class ResponseFormatter(ILogger logger) : IEndpointFilter { public async ValueTask InvokeAsync( EndpointFilterInvocationContext context, EndpointFilterDelegate next) { - var response = await next(context); - - if (response is not Output output) - { - return response; - } - - var statusCode = output.Status switch + try { - OutputStatus.Ok => StatusCodes.Status200OK, - OutputStatus.Created => StatusCodes.Status201Created, - OutputStatus.Empty => StatusCodes.Status204NoContent, - OutputStatus.InvalidInput => StatusCodes.Status400BadRequest, - OutputStatus.UnexistentId => StatusCodes.Status404NotFound, - OutputStatus.BusinessLogicViolation => StatusCodes.Status422UnprocessableEntity, - _ => StatusCodes.Status500InternalServerError, - }; - - if (statusCode == StatusCodes.Status204NoContent) + var response = await next(context); + + if (response is not Output output) + { + return response; + } + + var statusCode = output.Status switch + { + OutputStatus.Ok => StatusCodes.Status200OK, + OutputStatus.Created => StatusCodes.Status201Created, + OutputStatus.Empty => StatusCodes.Status204NoContent, + OutputStatus.InvalidInput => StatusCodes.Status400BadRequest, + OutputStatus.UnexistentId => StatusCodes.Status404NotFound, + OutputStatus.BusinessLogicViolation => StatusCodes.Status422UnprocessableEntity, + _ => StatusCodes.Status500InternalServerError, + }; + + if (statusCode == StatusCodes.Status204NoContent) + { + return Results.NoContent(); + } + + var res = output.Adapt>(); + + return Results.Json(res, statusCode: statusCode); + } + catch (Exception ex) { - return Results.NoContent(); - } + var message = ex is OperationCanceledException + ? "Oops! The process has taken too long" + : "Oops! An unexpected error occurred."; - var res = output.Adapt>(); + logger.LogError(ex, "{Error}", message); - return Results.Json(res, statusCode: statusCode); + return Results.Json( + data: new Response(null, [message]), + statusCode: StatusCodes.Status500InternalServerError + ); + } } } diff --git a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs index dc489ad..efbf470 100644 --- a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs +++ b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs @@ -3,11 +3,12 @@ using Kairos.Shared.Contracts.MarketData.GetStockQuotes; using Kairos.Shared.Extensions; using MediatR; +using Microsoft.Extensions.Logging; using Output = Kairos.Shared.Contracts.Output>; namespace Kairos.MarketData.Business.UseCases; -internal sealed class GetQuotesUseCase(IBrapi brapi) +internal sealed class GetQuotesUseCase(IBrapi brapi, ILogger logger) : IRequestHandler { static readonly string[] _testTickers = [ "PETR4", "MGLU3", "VALE3", "ITUB4" ]; @@ -38,6 +39,7 @@ public async Task Handle( } catch (Exception ex) { + logger.LogError(ex, "An error occurred while retrieving quotes. Input: {@Input}", input); return Output.UnexpectedError([ex.Message]); } } @@ -46,8 +48,6 @@ static async IAsyncEnumerable FormatQuotes(IEnumerable quotes { foreach (var quote in quotes) { - await Task.Delay(50); - yield return new Quote( quote.Date, quote.Close, diff --git a/src/MarketData/Infra/Dtos/QuoteResponse.cs b/src/MarketData/Infra/Dtos/QuoteResponse.cs index ff4e844..de745e1 100644 --- a/src/MarketData/Infra/Dtos/QuoteResponse.cs +++ b/src/MarketData/Infra/Dtos/QuoteResponse.cs @@ -1,6 +1,6 @@ namespace Kairos.MarketData.Infra.Dtos; -public sealed partial class QuoteResponse +public sealed class QuoteResponse { public required List Results { get; init; } From bc30ce8ec27cc93c871fc2f186463e3656b2a33b Mon Sep 17 00:00:00 2001 From: sagustavo Date: Thu, 27 Nov 2025 03:32:00 -0300 Subject: [PATCH 09/34] Fix OutOfRangeException bug --- src/MarketData/Business/UseCases/GetQuotesUseCase.cs | 6 ++++-- src/MarketData/Infra/Dtos/StockDetail.cs | 4 ++-- src/MarketData/Infra/Dtos/StockQuote.cs | 12 ++++++------ .../Contracts/MarketData/GetStockQuotes/Quote.cs | 2 +- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs index efbf470..522ce74 100644 --- a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs +++ b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs @@ -46,12 +46,14 @@ public async Task Handle( static async IAsyncEnumerable FormatQuotes(IEnumerable quotes) { + await Task.Yield(); + foreach (var quote in quotes) { yield return new Quote( quote.Date, - quote.Close, - quote.AdjustedClose); + quote.Close ?? 0, + quote.AdjustedClose ?? 0); } } diff --git a/src/MarketData/Infra/Dtos/StockDetail.cs b/src/MarketData/Infra/Dtos/StockDetail.cs index 3ff202e..2373829 100644 --- a/src/MarketData/Infra/Dtos/StockDetail.cs +++ b/src/MarketData/Infra/Dtos/StockDetail.cs @@ -6,7 +6,7 @@ public sealed class StockDetail { public required string Currency { get; init; } - public required long MarketCap { get; init; } + public required ulong MarketCap { get; init; } public required string ShortName { get; init; } @@ -26,7 +26,7 @@ public sealed class StockDetail public required double RegularMarketDayLow { get; init; } - public required long RegularMarketVolume { get; init; } + public required ulong RegularMarketVolume { get; init; } public required double RegularMarketPreviousClose { get; init; } diff --git a/src/MarketData/Infra/Dtos/StockQuote.cs b/src/MarketData/Infra/Dtos/StockQuote.cs index bdc7e05..5cbbae1 100644 --- a/src/MarketData/Infra/Dtos/StockQuote.cs +++ b/src/MarketData/Infra/Dtos/StockQuote.cs @@ -5,15 +5,15 @@ public sealed class StockQuote // Date formatted as Unix Timestamp (e.g., 1756126800) public required long Date { get; init; } - public required double Open { get; init; } + public required double? Open { get; init; } - public required double High { get; init; } + public required double? High { get; init; } - public required double Low { get; init; } + public required double? Low { get; init; } - public required double Close { get; init; } + public required double? Close { get; init; } - public required long Volume { get; init; } + public required ulong? Volume { get; init; } - public required double AdjustedClose { get; init; } + public required double? AdjustedClose { get; init; } } \ No newline at end of file diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/Quote.cs b/src/Shared/Contracts/MarketData/GetStockQuotes/Quote.cs index 1073461..9a1b433 100644 --- a/src/Shared/Contracts/MarketData/GetStockQuotes/Quote.cs +++ b/src/Shared/Contracts/MarketData/GetStockQuotes/Quote.cs @@ -7,7 +7,7 @@ double CloseWithEvents ) { public Quote(long unixTimeSeconds, double close, double adjustedClose) : this( - DateTimeOffset.FromUnixTimeSeconds(unixTimeSeconds).DateTime, + DateTimeOffset.FromUnixTimeSeconds(unixTimeSeconds).Date, close, adjustedClose ) { } From 37e0fcf9d61c730b8810df423a7e0f9573e2ed19 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Fri, 28 Nov 2025 00:34:50 -0300 Subject: [PATCH 10/34] Adding MongoDB connection --- Directory.Packages.props | 2 + src/Gateway/appsettings.json | 3 ++ .../Business/UseCases/GetQuotesUseCase.cs | 2 +- .../Configuration/BrapiHealthCheck.cs | 2 +- src/MarketData/Configuration/Settings.cs | 7 +++- src/MarketData/DependencyInjection.cs | 40 ++++++++++++++++--- .../Infra/{ => Abstractions}/IBrapi.cs | 2 +- .../Infra/Abstractions/IStockRepository.cs | 8 ++++ src/MarketData/Infra/StockRepository.cs | 12 ++++++ src/MarketData/Kairos.MarketData.csproj | 5 +++ src/Shared/Configuration/DbOptions.cs | 13 ++++++ 11 files changed, 86 insertions(+), 10 deletions(-) rename src/MarketData/Infra/{ => Abstractions}/IBrapi.cs (85%) create mode 100644 src/MarketData/Infra/Abstractions/IStockRepository.cs create mode 100644 src/MarketData/Infra/StockRepository.cs create mode 100644 src/Shared/Configuration/DbOptions.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 62e40d1..ea16ab1 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,6 +4,7 @@ + @@ -25,6 +26,7 @@ + diff --git a/src/Gateway/appsettings.json b/src/Gateway/appsettings.json index e26bc15..f539ec8 100644 --- a/src/Gateway/appsettings.json +++ b/src/Gateway/appsettings.json @@ -18,6 +18,9 @@ "Database": { "Broker": { "ConnectionString": "Database:Broker:ConnectionString" + }, + "MarketData": { + "ConnectionString": "Database:MarketData:ConnectionString" } }, "Serilog": { diff --git a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs index 522ce74..ae71e7f 100644 --- a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs +++ b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs @@ -1,4 +1,4 @@ -using Kairos.MarketData.Infra; +using Kairos.MarketData.Infra.Abstractions; using Kairos.MarketData.Infra.Dtos; using Kairos.Shared.Contracts.MarketData.GetStockQuotes; using Kairos.Shared.Extensions; diff --git a/src/MarketData/Configuration/BrapiHealthCheck.cs b/src/MarketData/Configuration/BrapiHealthCheck.cs index 82c0505..ea21655 100644 --- a/src/MarketData/Configuration/BrapiHealthCheck.cs +++ b/src/MarketData/Configuration/BrapiHealthCheck.cs @@ -1,4 +1,4 @@ -using Kairos.MarketData.Infra; +using Kairos.MarketData.Infra.Abstractions; using Kairos.MarketData.Infra.Dtos; using Microsoft.Extensions.Diagnostics.HealthChecks; diff --git a/src/MarketData/Configuration/Settings.cs b/src/MarketData/Configuration/Settings.cs index bbb335b..02614a2 100644 --- a/src/MarketData/Configuration/Settings.cs +++ b/src/MarketData/Configuration/Settings.cs @@ -2,10 +2,15 @@ namespace Kairos.MarketData.Configuration; -public static partial class Settings +internal static partial class Settings { public sealed partial class Api { public required ApiOptions Brapi { get; init; } } + + public sealed partial class Database + { + public required DbOptions MarketData { get; init; } + } } \ No newline at end of file diff --git a/src/MarketData/DependencyInjection.cs b/src/MarketData/DependencyInjection.cs index 2047a94..f7d6ad7 100644 --- a/src/MarketData/DependencyInjection.cs +++ b/src/MarketData/DependencyInjection.cs @@ -1,12 +1,14 @@ using System.Reflection; using System.Text.Json; using Kairos.MarketData.Configuration; -using Kairos.MarketData.Infra; -using Kairos.Shared.Configuration; +using Kairos.MarketData.Infra.Abstractions; using Kairos.Shared.Infra.HttpClient; +using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Options; +using MongoDB.Driver; using Polly; using Polly.Contrib.WaitAndRetry; using Refit; @@ -20,14 +22,15 @@ public static IServiceCollection AddMarketData( IConfigurationManager config) { services.Configure(config.GetSection("Api")); - + var api = services.BuildServiceProvider() .GetRequiredService>() .Value; return services .AddApiClients(api) - .AddHealthCheck(api) + .AddDatabase(config) + .AddHealthCheck() .AddMediatR(cfg => { cfg.LicenseKey = config["Keys:MediatR"]; @@ -69,10 +72,35 @@ static IServiceCollection AddApiClients(this IServiceCollection services, Settin return services; } - static IServiceCollection AddHealthCheck(this IServiceCollection services, Settings.Api api) + static IServiceCollection AddHealthCheck(this IServiceCollection services) { - services.AddHealthChecks().AddCheck("brapi"); + services.AddHealthChecks() + .AddCheck("brapi") + .AddMongoDb( + dbFactory: sp => sp.GetRequiredService(), + tags: ["db", "mongo"], + failureStatus: HealthStatus.Unhealthy, + name: "mongodb" + ); return services; } + + static IServiceCollection AddDatabase( + this IServiceCollection services, + IConfigurationManager config) + { + services.Configure(config.GetSection("Database")); + + return services.AddSingleton(sp => + { + var connString = sp.GetRequiredService>() + .Value.MarketData + .ConnectionString; + + var marketDataDb = MongoUrl.Create(connString).DatabaseName; + + return new MongoClient(connString).GetDatabase(marketDataDb); + }); + } } diff --git a/src/MarketData/Infra/IBrapi.cs b/src/MarketData/Infra/Abstractions/IBrapi.cs similarity index 85% rename from src/MarketData/Infra/IBrapi.cs rename to src/MarketData/Infra/Abstractions/IBrapi.cs index e479501..753f46a 100644 --- a/src/MarketData/Infra/IBrapi.cs +++ b/src/MarketData/Infra/Abstractions/IBrapi.cs @@ -1,7 +1,7 @@ using Kairos.MarketData.Infra.Dtos; using Refit; -namespace Kairos.MarketData.Infra; +namespace Kairos.MarketData.Infra.Abstractions; /// /// Open API reference for brapi.dev: https://brapi.dev/swagger/latest.json diff --git a/src/MarketData/Infra/Abstractions/IStockRepository.cs b/src/MarketData/Infra/Abstractions/IStockRepository.cs new file mode 100644 index 0000000..dab6436 --- /dev/null +++ b/src/MarketData/Infra/Abstractions/IStockRepository.cs @@ -0,0 +1,8 @@ +using System; + +namespace Kairos.MarketData.Infra.Abstractions; + +public interface IStockRepository +{ + +} diff --git a/src/MarketData/Infra/StockRepository.cs b/src/MarketData/Infra/StockRepository.cs new file mode 100644 index 0000000..c2133fd --- /dev/null +++ b/src/MarketData/Infra/StockRepository.cs @@ -0,0 +1,12 @@ +using Kairos.MarketData.Infra.Abstractions; +using Kairos.MarketData.Infra.Dtos; +using Kairos.Shared.Contracts.MarketData.GetStockQuotes; +using MongoDB.Driver; + +namespace Kairos.MarketData.Infra; + +internal sealed class StockRepository(IMongoDatabase db) : IStockRepository +{ + readonly IMongoCollection _stocks = db.GetCollection("Stock"); + readonly IMongoCollection _prices = db.GetCollection("Price"); +} diff --git a/src/MarketData/Kairos.MarketData.csproj b/src/MarketData/Kairos.MarketData.csproj index a42b9c7..4e593c1 100644 --- a/src/MarketData/Kairos.MarketData.csproj +++ b/src/MarketData/Kairos.MarketData.csproj @@ -4,6 +4,11 @@ + + + + + net8.0 enable diff --git a/src/Shared/Configuration/DbOptions.cs b/src/Shared/Configuration/DbOptions.cs new file mode 100644 index 0000000..6144f22 --- /dev/null +++ b/src/Shared/Configuration/DbOptions.cs @@ -0,0 +1,13 @@ +namespace Kairos.Shared.Configuration; + +public class DbOptions +{ + public required string ConnectionString { get; init; } + + /// + /// Request timeout in seconds + /// + public int Timeout { get; init; } + + public ResilienceOptions? Resilience { get; init; } +} From f35d2915e45f5f287c11925f5c8fd5481e2d5d0c Mon Sep 17 00:00:00 2001 From: sagustavo Date: Fri, 28 Nov 2025 23:37:22 -0300 Subject: [PATCH 11/34] Fix brapi quote casting --- src/MarketData/Infra/Dtos/QuoteResponse.cs | 2 +- src/MarketData/Infra/Dtos/StockDetail.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MarketData/Infra/Dtos/QuoteResponse.cs b/src/MarketData/Infra/Dtos/QuoteResponse.cs index de745e1..0141137 100644 --- a/src/MarketData/Infra/Dtos/QuoteResponse.cs +++ b/src/MarketData/Infra/Dtos/QuoteResponse.cs @@ -6,5 +6,5 @@ public sealed class QuoteResponse public required DateTime RequestedAt { get; init; } - public required string Took { get; init; } + public required int Took { get; init; } } \ No newline at end of file diff --git a/src/MarketData/Infra/Dtos/StockDetail.cs b/src/MarketData/Infra/Dtos/StockDetail.cs index 2373829..9f3b2a3 100644 --- a/src/MarketData/Infra/Dtos/StockDetail.cs +++ b/src/MarketData/Infra/Dtos/StockDetail.cs @@ -6,7 +6,7 @@ public sealed class StockDetail { public required string Currency { get; init; } - public required ulong MarketCap { get; init; } + public required double MarketCap { get; init; } public required string ShortName { get; init; } From 001d1e48e689b7c90fbf0c1a7229214248c93654 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sat, 29 Nov 2025 00:48:24 -0300 Subject: [PATCH 12/34] Creating Price mongo collection --- .editorconfig | 3 ++ src/MarketData/DependencyInjection.cs | 47 +++++++++++++++---- .../Infra/Abstractions/IStockRepository.cs | 11 ++++- src/MarketData/Infra/Dtos/Price.cs | 8 ++++ src/MarketData/Infra/StockRepository.cs | 40 +++++++++++++++- 5 files changed, 97 insertions(+), 12 deletions(-) create mode 100644 src/MarketData/Infra/Dtos/Price.cs diff --git a/.editorconfig b/.editorconfig index 64b3b07..ac7d1d8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -531,5 +531,8 @@ dotnet_diagnostic.S2139.severity = none # CA1707: Remove the underscores from member name dotnet_diagnostic.CA1707.severity = none +# S1125: Remove the unnecessary Boolean literal(s). +dotnet_diagnostic.S1125.severity = none + [**/Migrations/*] generated_code = true \ No newline at end of file diff --git a/src/MarketData/DependencyInjection.cs b/src/MarketData/DependencyInjection.cs index f7d6ad7..e209c95 100644 --- a/src/MarketData/DependencyInjection.cs +++ b/src/MarketData/DependencyInjection.cs @@ -1,5 +1,7 @@ -using System.Reflection; +using System.Data.Common; +using System.Reflection; using System.Text.Json; +using System.Threading.Tasks; using Kairos.MarketData.Configuration; using Kairos.MarketData.Infra.Abstractions; using Kairos.Shared.Infra.HttpClient; @@ -8,6 +10,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Options; +using MongoDB.Bson; using MongoDB.Driver; using Polly; using Polly.Contrib.WaitAndRetry; @@ -17,7 +20,7 @@ namespace Kairos.MarketData; public static class DependencyInjection { - public static IServiceCollection AddMarketData( + public static async Task AddMarketData( this IServiceCollection services, IConfigurationManager config) { @@ -27,9 +30,10 @@ public static IServiceCollection AddMarketData( .GetRequiredService>() .Value; + services.AddDatabase(config).Wait(); + return services .AddApiClients(api) - .AddDatabase(config) .AddHealthCheck() .AddMediatR(cfg => { @@ -86,21 +90,48 @@ static IServiceCollection AddHealthCheck(this IServiceCollection services) return services; } - static IServiceCollection AddDatabase( + async static Task AddDatabase( this IServiceCollection services, IConfigurationManager config) { services.Configure(config.GetSection("Database")); - return services.AddSingleton(sp => + services.AddSingleton(sp => { - var connString = sp.GetRequiredService>() - .Value.MarketData - .ConnectionString; + var settings = services.BuildServiceProvider() + .GetRequiredService>() + .Value; + var connString = settings.MarketData.ConnectionString; var marketDataDb = MongoUrl.Create(connString).DatabaseName; return new MongoClient(connString).GetDatabase(marketDataDb); }); + + var db = services + .BuildServiceProvider() + .GetRequiredService(); + + const string priceCollection = "Price"; + + var collections = await db.ListCollectionsAsync(new ListCollectionsOptions + { + Filter = new BsonDocument("name", priceCollection) + }); + + if (await collections.AnyAsync() is false) + { + var options = new CreateCollectionOptions + { + TimeSeriesOptions = new TimeSeriesOptions( + timeField: "Date", + metaField: "Ticker", + granularity: TimeSeriesGranularity.Seconds) + }; + + await db.CreateCollectionAsync(priceCollection, options); + } + + return services; } } diff --git a/src/MarketData/Infra/Abstractions/IStockRepository.cs b/src/MarketData/Infra/Abstractions/IStockRepository.cs index dab6436..9e33e24 100644 --- a/src/MarketData/Infra/Abstractions/IStockRepository.cs +++ b/src/MarketData/Infra/Abstractions/IStockRepository.cs @@ -1,8 +1,15 @@ -using System; +using System.Runtime.CompilerServices; +using Kairos.MarketData.Infra.Dtos; namespace Kairos.MarketData.Infra.Abstractions; -public interface IStockRepository +internal interface IStockRepository { + IAsyncEnumerable GetPrices( + string ticker, + DateTime from, + DateTime to, + [EnumeratorCancellation] CancellationToken ct); + Task AddPrices(IEnumerable prices, CancellationToken ct); } diff --git a/src/MarketData/Infra/Dtos/Price.cs b/src/MarketData/Infra/Dtos/Price.cs new file mode 100644 index 0000000..066efd0 --- /dev/null +++ b/src/MarketData/Infra/Dtos/Price.cs @@ -0,0 +1,8 @@ +namespace Kairos.MarketData.Infra.Dtos; + +internal sealed record Price( + string Ticker, + DateTime Date, + decimal Value, + decimal AdjustedValue +); \ No newline at end of file diff --git a/src/MarketData/Infra/StockRepository.cs b/src/MarketData/Infra/StockRepository.cs index c2133fd..997940c 100644 --- a/src/MarketData/Infra/StockRepository.cs +++ b/src/MarketData/Infra/StockRepository.cs @@ -1,6 +1,6 @@ +using System.Runtime.CompilerServices; using Kairos.MarketData.Infra.Abstractions; using Kairos.MarketData.Infra.Dtos; -using Kairos.Shared.Contracts.MarketData.GetStockQuotes; using MongoDB.Driver; namespace Kairos.MarketData.Infra; @@ -8,5 +8,41 @@ namespace Kairos.MarketData.Infra; internal sealed class StockRepository(IMongoDatabase db) : IStockRepository { readonly IMongoCollection _stocks = db.GetCollection("Stock"); - readonly IMongoCollection _prices = db.GetCollection("Price"); + readonly IMongoCollection _prices = db.GetCollection("Price"); + + public async IAsyncEnumerable GetPrices( + string ticker, + DateTime from, + DateTime to, + [EnumeratorCancellation] CancellationToken ct) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(x => x.Ticker, ticker), + Builders.Filter.Gte(x => x.Date, from), + Builders.Filter.Lte(x => x.Date, to) + ); + + var options = new FindOptions + { + Projection = Builders.Projection.Exclude("_id") + }; + + using var prices = await _prices.FindAsync(filter, options, ct); + + while (await prices.MoveNextAsync(cancellationToken: ct)) + { + foreach (var price in prices.Current) + { + yield return price; + } + } + } + + public Task AddPrices(IEnumerable prices, CancellationToken ct) + { + return _prices.InsertManyAsync( + prices, + new InsertManyOptions() { IsOrdered = false }, + ct); + } } From 6166db0df44ff2afe5f9cdc61aa2bbc9e100e4e2 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sat, 29 Nov 2025 03:43:15 -0300 Subject: [PATCH 13/34] Caching price data in MongoDB --- Directory.Packages.props | 1 + .../Business/Extensions/QuoteExtensions.cs | 43 +++++++++ .../Business/UseCases/GetQuotesUseCase.cs | 88 ++++++++++++------- src/MarketData/DependencyInjection.cs | 9 +- .../Infra/Abstractions/IStockRepository.cs | 2 +- src/MarketData/Infra/Dtos/Price.cs | 14 ++- src/MarketData/Infra/Dtos/StockQuote.cs | 10 +-- src/MarketData/Infra/StockRepository.cs | 3 +- .../MarketData/GetStockQuotes/Quote.cs | 6 +- .../MarketData/GetStockQuotes/QuoteRange.cs | 2 +- src/Shared/Extensions/QuoteRangeExtensions.cs | 83 +++++++++++++++++ src/Shared/Kairos.Shared.csproj | 1 + 12 files changed, 209 insertions(+), 53 deletions(-) create mode 100644 src/MarketData/Business/Extensions/QuoteExtensions.cs create mode 100644 src/Shared/Extensions/QuoteRangeExtensions.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index ea16ab1..c63025b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -43,6 +43,7 @@ + diff --git a/src/MarketData/Business/Extensions/QuoteExtensions.cs b/src/MarketData/Business/Extensions/QuoteExtensions.cs new file mode 100644 index 0000000..a90512a --- /dev/null +++ b/src/MarketData/Business/Extensions/QuoteExtensions.cs @@ -0,0 +1,43 @@ +using Kairos.MarketData.Infra.Dtos; +using Kairos.Shared.Contracts.MarketData.GetStockQuotes; + +namespace Kairos.MarketData.Business.Extensions; + +public static class QuoteExtensions +{ + internal static async IAsyncEnumerable ToStreamedQuote(this IEnumerable quotes) + { + foreach (var quote in quotes) + { + yield return new Quote( + quote.Date, + quote.Close ?? 0, + quote.AdjustedClose ?? 0); + } + } + + internal static async IAsyncEnumerable ToStreamedQuote(this IEnumerable prices) + { + foreach (var price in prices) + { + yield return new Quote( + price.Date, + price.Value, + price.AdjustedValue); + } + } + + internal static IEnumerable ToPrices( + this IEnumerable quotes, + string ticker) + { + foreach (var quote in quotes) + { + yield return new Price( + ticker, + quote.Date, + quote.Close ?? 0, + quote.AdjustedClose ?? 0); + } + } +} diff --git a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs index ae71e7f..58c6bcd 100644 --- a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs +++ b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs @@ -1,41 +1,43 @@ +using Kairos.MarketData.Business.Extensions; using Kairos.MarketData.Infra.Abstractions; using Kairos.MarketData.Infra.Dtos; using Kairos.Shared.Contracts.MarketData.GetStockQuotes; using Kairos.Shared.Extensions; +using MassTransit.Internals; using MediatR; using Microsoft.Extensions.Logging; +using MongoDB.Driver.Linq; using Output = Kairos.Shared.Contracts.Output>; namespace Kairos.MarketData.Business.UseCases; -internal sealed class GetQuotesUseCase(IBrapi brapi, ILogger logger) +internal sealed class GetQuotesUseCase( + IBrapi brapi, + ILogger logger, + IStockRepository repo) : IRequestHandler { - static readonly string[] _testTickers = [ "PETR4", "MGLU3", "VALE3", "ITUB4" ]; - static readonly QuoteRange[] _freeRanges = [ - QuoteRange.Day, - QuoteRange.FiveDays, - QuoteRange.Month, - QuoteRange.Quarter - ]; - public async Task Handle( GetQuotesQuery input, CancellationToken cancellationToken) { try { - QuoteResponse? quoteRes = await brapi.GetQuote( - input.Ticker, - GetValidRange(input).GetDescription()); + var range = input.Range.GetCompatibleRange(input.Ticker); + + var prices = await repo + .GetPrices(input.Ticker, range.GetMinDate(), cancellationToken) + .ToListAsync(); - List quotes = quoteRes.Results[0].HistoricalDataPrice; + var historicalPriceUpToDate = prices + .Any(p => p.Date >= DateTime.Today.AddDays(-3)); - return quotes.Count switch + if (historicalPriceUpToDate is false) { - 0 => Output.Empty, - _ => Output.Ok(FormatQuotes(quotes)) - }; + return await GetUpToDatePrices(input, range); + } + + return Output.Ok(prices.ToStreamedQuote()); } catch (Exception ex) { @@ -44,31 +46,49 @@ public async Task Handle( } } - static async IAsyncEnumerable FormatQuotes(IEnumerable quotes) + async Task GetUpToDatePrices(GetQuotesQuery input, QuoteRange range) { - await Task.Yield(); + QuoteResponse? quoteRes = await brapi.GetQuote( + input.Ticker, + QuoteRange.Max.GetCompatibleRange(input.Ticker).GetDescription()); - foreach (var quote in quotes) - { - yield return new Quote( - quote.Date, - quote.Close ?? 0, - quote.AdjustedClose ?? 0); - } + List quotes = quoteRes.Results[0].HistoricalDataPrice; + + Task.Run(async () => await SyncPriceData(quotes, input.Ticker)); + + var pricesInsideRange = quotes + .ToStreamedQuote() + .Where(p => p.Date >= range.GetMinDate()); + + return Output.Ok(pricesInsideRange); } - static QuoteRange GetValidRange(GetQuotesQuery input) + async Task SyncPriceData(List quotes, string ticker) { - if (_freeRanges.Contains(input.Range)) + const string method = nameof(SyncPriceData); + + if (quotes.Count == 0) { - return input.Range; + return; } - if (_testTickers.Contains(input.Ticker)) + try + { + logger.LogInformation( + "[{Method}] Synchronizing {Ticker} prices from {FromDate} to {ToDate}", + method, + ticker, + quotes.MinBy(q => q.Date)!.Date, + quotes.MaxBy(q => q.Date)!.Date); + + await repo.AddPrices(quotes.ToPrices(ticker), CancellationToken.None); + } + catch (Exception ex) { - return input.Range; + logger.LogInformation( + ex, + "[{Method}] An unexpected error occurred", + method); } - - return QuoteRange.Quarter; } -} +} \ No newline at end of file diff --git a/src/MarketData/DependencyInjection.cs b/src/MarketData/DependencyInjection.cs index e209c95..cd5a76e 100644 --- a/src/MarketData/DependencyInjection.cs +++ b/src/MarketData/DependencyInjection.cs @@ -1,11 +1,9 @@ -using System.Data.Common; -using System.Reflection; +using System.Reflection; using System.Text.Json; -using System.Threading.Tasks; using Kairos.MarketData.Configuration; +using Kairos.MarketData.Infra; using Kairos.MarketData.Infra.Abstractions; using Kairos.Shared.Infra.HttpClient; -using Microsoft.AspNetCore.Mvc.Routing; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -132,6 +130,7 @@ async static Task AddDatabase( await db.CreateCollectionAsync(priceCollection, options); } - return services; + return services + .AddSingleton(); } } diff --git a/src/MarketData/Infra/Abstractions/IStockRepository.cs b/src/MarketData/Infra/Abstractions/IStockRepository.cs index 9e33e24..45285fe 100644 --- a/src/MarketData/Infra/Abstractions/IStockRepository.cs +++ b/src/MarketData/Infra/Abstractions/IStockRepository.cs @@ -1,5 +1,6 @@ using System.Runtime.CompilerServices; using Kairos.MarketData.Infra.Dtos; +using Kairos.Shared.Contracts.MarketData.GetStockQuotes; namespace Kairos.MarketData.Infra.Abstractions; @@ -8,7 +9,6 @@ internal interface IStockRepository IAsyncEnumerable GetPrices( string ticker, DateTime from, - DateTime to, [EnumeratorCancellation] CancellationToken ct); Task AddPrices(IEnumerable prices, CancellationToken ct); diff --git a/src/MarketData/Infra/Dtos/Price.cs b/src/MarketData/Infra/Dtos/Price.cs index 066efd0..3a54519 100644 --- a/src/MarketData/Infra/Dtos/Price.cs +++ b/src/MarketData/Infra/Dtos/Price.cs @@ -4,5 +4,15 @@ internal sealed record Price( string Ticker, DateTime Date, decimal Value, - decimal AdjustedValue -); \ No newline at end of file + decimal AdjustedValue, + DateTime CreatedAt +) +{ + public Price(string ticker, long unixTimeSeconds, decimal value, decimal adjustedValue) : this( + ticker, + DateTimeOffset.FromUnixTimeSeconds(unixTimeSeconds).Date, + value, + adjustedValue, + DateTime.UtcNow + ) { } +} \ No newline at end of file diff --git a/src/MarketData/Infra/Dtos/StockQuote.cs b/src/MarketData/Infra/Dtos/StockQuote.cs index 5cbbae1..cc5cf6d 100644 --- a/src/MarketData/Infra/Dtos/StockQuote.cs +++ b/src/MarketData/Infra/Dtos/StockQuote.cs @@ -5,15 +5,15 @@ public sealed class StockQuote // Date formatted as Unix Timestamp (e.g., 1756126800) public required long Date { get; init; } - public required double? Open { get; init; } + public required decimal? Open { get; init; } - public required double? High { get; init; } + public required decimal? High { get; init; } - public required double? Low { get; init; } + public required decimal? Low { get; init; } - public required double? Close { get; init; } + public required decimal? Close { get; init; } public required ulong? Volume { get; init; } - public required double? AdjustedClose { get; init; } + public required decimal? AdjustedClose { get; init; } } \ No newline at end of file diff --git a/src/MarketData/Infra/StockRepository.cs b/src/MarketData/Infra/StockRepository.cs index 997940c..0286866 100644 --- a/src/MarketData/Infra/StockRepository.cs +++ b/src/MarketData/Infra/StockRepository.cs @@ -13,13 +13,12 @@ internal sealed class StockRepository(IMongoDatabase db) : IStockRepository public async IAsyncEnumerable GetPrices( string ticker, DateTime from, - DateTime to, [EnumeratorCancellation] CancellationToken ct) { var filter = Builders.Filter.And( Builders.Filter.Eq(x => x.Ticker, ticker), Builders.Filter.Gte(x => x.Date, from), - Builders.Filter.Lte(x => x.Date, to) + Builders.Filter.Lte(x => x.Date, DateTime.Today) ); var options = new FindOptions diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/Quote.cs b/src/Shared/Contracts/MarketData/GetStockQuotes/Quote.cs index 9a1b433..c6c63c7 100644 --- a/src/Shared/Contracts/MarketData/GetStockQuotes/Quote.cs +++ b/src/Shared/Contracts/MarketData/GetStockQuotes/Quote.cs @@ -2,11 +2,11 @@ namespace Kairos.Shared.Contracts.MarketData.GetStockQuotes; public sealed record Quote( DateTime Date, - double Close, - double CloseWithEvents + decimal Close, + decimal CloseWithEvents ) { - public Quote(long unixTimeSeconds, double close, double adjustedClose) : this( + public Quote(long unixTimeSeconds, decimal close, decimal adjustedClose) : this( DateTimeOffset.FromUnixTimeSeconds(unixTimeSeconds).Date, close, adjustedClose diff --git a/src/Shared/Contracts/MarketData/GetStockQuotes/QuoteRange.cs b/src/Shared/Contracts/MarketData/GetStockQuotes/QuoteRange.cs index b4299f8..386a453 100644 --- a/src/Shared/Contracts/MarketData/GetStockQuotes/QuoteRange.cs +++ b/src/Shared/Contracts/MarketData/GetStockQuotes/QuoteRange.cs @@ -5,7 +5,7 @@ namespace Kairos.Shared.Contracts.MarketData.GetStockQuotes; public enum QuoteRange { [Description("1d")] - Day, + Day = 1, [Description("5d")] FiveDays, diff --git a/src/Shared/Extensions/QuoteRangeExtensions.cs b/src/Shared/Extensions/QuoteRangeExtensions.cs new file mode 100644 index 0000000..8b55e99 --- /dev/null +++ b/src/Shared/Extensions/QuoteRangeExtensions.cs @@ -0,0 +1,83 @@ +using Kairos.Shared.Contracts.MarketData.GetStockQuotes; +using MassTransit; + +namespace Kairos.Shared.Extensions; + +public static class QuoteRangeExtensions +{ + static readonly string[] _testTickers = [ "PETR4", "MGLU3", "VALE3", "ITUB4" ]; + static readonly QuoteRange[] _freeRanges = [ + QuoteRange.Day, + QuoteRange.FiveDays, + QuoteRange.Month, + QuoteRange.Quarter + ]; + + /// + /// Gets a brapi.dev compatible range for a given ticker + /// + /// + /// If ticker is PETR4, MGLU3, VALE3, ITUB4, any range is compatible. + /// + /// + /// + /// + public static QuoteRange GetCompatibleRange( + this QuoteRange range, + string ticker) + { + if (_freeRanges.Contains(range)) + { + return range; + } + + if (_testTickers.Contains(ticker)) + { + return range; + } + + QuoteRange compatibleRange = _freeRanges + .FirstOrDefault(r => r.ToNumber() >= range.ToNumber()); + + return compatibleRange is 0 ? QuoteRange.Quarter : compatibleRange; + } + + /// + /// Gets the amount of days inside the given range. + /// + /// e.g, QuoteRange.Week has 7 days + /// + /// + public static int ToNumber(this QuoteRange range) + { + const int month = 30; + const int year = month * 12; + + return range switch + { + QuoteRange.Day => 1, + QuoteRange.FiveDays => 5, + QuoteRange.Week => 7, + QuoteRange.Month => month, + QuoteRange.Quarter => month * 3, + QuoteRange.Semester => month * 6, + QuoteRange.Year => year, + QuoteRange.TwoYears => year * 2, + QuoteRange.FiveYears => year * 5, + QuoteRange.Decade => year * 10, + QuoteRange.YearToDate => DateTime.Today.DayOfYear, + _ => int.MaxValue + }; + } + + /// + /// Gets the range start date. + /// + /// e.g., if today is january 10th and QuoteRange.Week, then the min date is january 3rd (10 - 7) + /// + /// + public static DateTime GetMinDate(this QuoteRange range) => + DateTime.Today + .AddDays(-ToNumber(range)) + .Date; +} diff --git a/src/Shared/Kairos.Shared.csproj b/src/Shared/Kairos.Shared.csproj index a9d4632..8cfef2f 100644 --- a/src/Shared/Kairos.Shared.csproj +++ b/src/Shared/Kairos.Shared.csproj @@ -26,5 +26,6 @@ + \ No newline at end of file From c43f2a160e694d73646827b896d6d23fe69cf81d Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sat, 29 Nov 2025 04:16:51 -0300 Subject: [PATCH 14/34] Unit tests --- Directory.Packages.props | 2 + Kairos.sln | 7 + src/Gateway/Modules/MarketDataModule.cs | 2 +- src/MarketData/Kairos.MarketData.csproj | 2 + src/Shared/Extensions/QuoteRangeExtensions.cs | 1 - .../UseCases/GetQuotesUseCaseTests.cs | 169 ++++++++++++++++++ .../Kairos.MarketData.UnitTests.csproj | 38 ++++ tests/MarketData.UnitTests/xunit.runner.json | 3 + 8 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 tests/MarketData.UnitTests/Business/UseCases/GetQuotesUseCaseTests.cs create mode 100644 tests/MarketData.UnitTests/Kairos.MarketData.UnitTests.csproj create mode 100644 tests/MarketData.UnitTests/xunit.runner.json diff --git a/Directory.Packages.props b/Directory.Packages.props index c63025b..a52cc85 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,7 @@ + @@ -27,6 +28,7 @@ + diff --git a/Kairos.sln b/Kairos.sln index e0ecd3e..b85e01b 100644 --- a/Kairos.sln +++ b/Kairos.sln @@ -28,6 +28,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kairos.Account.UnitTests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kairos.MarketData", "src\MarketData\Kairos.MarketData.csproj", "{1CC788E6-AEC0-41C8-8096-7003084611BD}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Kairos.MarketData.UnitTests", "tests\MarketData.UnitTests\Kairos.MarketData.UnitTests.csproj", "{A23931DE-69A1-4697-A80F-23926EEE1919}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -54,6 +56,10 @@ Global {1CC788E6-AEC0-41C8-8096-7003084611BD}.Debug|Any CPU.Build.0 = Debug|Any CPU {1CC788E6-AEC0-41C8-8096-7003084611BD}.Release|Any CPU.ActiveCfg = Release|Any CPU {1CC788E6-AEC0-41C8-8096-7003084611BD}.Release|Any CPU.Build.0 = Release|Any CPU + {A23931DE-69A1-4697-A80F-23926EEE1919}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A23931DE-69A1-4697-A80F-23926EEE1919}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A23931DE-69A1-4697-A80F-23926EEE1919}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A23931DE-69A1-4697-A80F-23926EEE1919}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -64,5 +70,6 @@ Global {FAE8E1FC-4467-4C08-833A-E113A8C042E8} = {4FC691C0-CFC2-497C-AF12-B105468E9BDF} {76D3F07E-1A29-4EB7-BD4B-6D8D65B118C5} = {4361FCFA-A93A-422D-9C63-6328F9B0A8AC} {1CC788E6-AEC0-41C8-8096-7003084611BD} = {4FC691C0-CFC2-497C-AF12-B105468E9BDF} + {A23931DE-69A1-4697-A80F-23926EEE1919} = {4361FCFA-A93A-422D-9C63-6328F9B0A8AC} EndGlobalSection EndGlobal diff --git a/src/Gateway/Modules/MarketDataModule.cs b/src/Gateway/Modules/MarketDataModule.cs index d95109f..0444bfb 100644 --- a/src/Gateway/Modules/MarketDataModule.cs +++ b/src/Gateway/Modules/MarketDataModule.cs @@ -32,6 +32,6 @@ public override void AddRoutes(IEndpointRouteBuilder app) [FromRoute] string ticker, [FromQuery] QuoteRange? range = null) => _mediator.Send(new GetQuotesQuery(ticker, range))) - .WithDescription("Get a stock's historical quotes"); + .WithDescription("Get a stock's historical quotes.\n\n Tickers for test: PETR4, MGLU3, VALE3 and ITUB4 "); } } \ No newline at end of file diff --git a/src/MarketData/Kairos.MarketData.csproj b/src/MarketData/Kairos.MarketData.csproj index 4e593c1..a9788a1 100644 --- a/src/MarketData/Kairos.MarketData.csproj +++ b/src/MarketData/Kairos.MarketData.csproj @@ -2,6 +2,8 @@ + + diff --git a/src/Shared/Extensions/QuoteRangeExtensions.cs b/src/Shared/Extensions/QuoteRangeExtensions.cs index 8b55e99..b6e2e4d 100644 --- a/src/Shared/Extensions/QuoteRangeExtensions.cs +++ b/src/Shared/Extensions/QuoteRangeExtensions.cs @@ -1,5 +1,4 @@ using Kairos.Shared.Contracts.MarketData.GetStockQuotes; -using MassTransit; namespace Kairos.Shared.Extensions; diff --git a/tests/MarketData.UnitTests/Business/UseCases/GetQuotesUseCaseTests.cs b/tests/MarketData.UnitTests/Business/UseCases/GetQuotesUseCaseTests.cs new file mode 100644 index 0000000..e189e7b --- /dev/null +++ b/tests/MarketData.UnitTests/Business/UseCases/GetQuotesUseCaseTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using FluentAssertions; +using Kairos.MarketData.Business.UseCases; +using Kairos.MarketData.Infra.Abstractions; +using Kairos.MarketData.Infra.Dtos; +using Kairos.Shared.Contracts.MarketData.GetStockQuotes; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using Output = Kairos.Shared.Contracts.Output>; + +namespace Kairos.MarketData.UnitTests.Business.UseCases; + +public sealed class GetQuotesUseCaseTests +{ + private readonly Fixture _fixture; + private readonly Mock _brapi; + private readonly Mock _repo; + private readonly Mock> _logger; + private readonly GetQuotesUseCase _sut; + + public GetQuotesUseCaseTests() + { + _fixture = new Fixture(); + _brapi = new Mock(); + _repo = new Mock(); + _logger = new Mock>(); + _sut = new GetQuotesUseCase(_brapi.Object, _logger.Object, _repo.Object); + } + + [Fact] + public async Task Handle_WhenDatabaseHasUpToDatePrices_ShouldReturnPricesFromDatabase() + { + // Arrange + var query = new GetQuotesQuery("PETR4", QuoteRange.FiveDays); + var dbPrices = new List + { + new("PETR4", DateTime.Today.ToUnixTimeSeconds(), 30, 30) + }; + + _repo.Setup(r => r.GetPrices(query.Ticker, It.IsAny(), It.IsAny())) + .Returns(dbPrices.ToAsyncEnumerable()); + + // Act + var result = await _sut.Handle(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + var quotes = await result.Value.ToListAsync(); + quotes.Should().HaveCount(1); + quotes[0].Close.Should().Be(30); + + _brapi.Verify(b => b.GetQuote(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WhenDatabaseHasOutdatedPrices_ShouldFetchFromApiAndReturnFilteredPrices() + { + // Arrange + var query = new GetQuotesQuery("PETR4", QuoteRange.Day); + var outdatedDbPrices = new List + { + new("PETR4", DateTime.Today.AddDays(-10).ToUnixTimeSeconds(), 25, 25) + }; + var apiQuotes = new List + { + new() { Date = DateTime.Today.ToUnixTimeSeconds(), Close = 30, AdjustedClose = 30 }, + new() { Date = DateTime.Today.AddDays(-10).ToUnixTimeSeconds(), Close = 25, AdjustedClose = 25 } + }; + var quoteResponse = _fixture.Build() + .With(r => r.Results, [_fixture.Build().With(qr => qr.HistoricalDataPrice, apiQuotes).Create()]) + .Create(); + + _repo.Setup(r => r.GetPrices(query.Ticker, It.IsAny(), It.IsAny())) + .Returns(outdatedDbPrices.ToAsyncEnumerable()); + _brapi.Setup(b => b.GetQuote(query.Ticker, It.IsAny(), It.IsAny())) + .ReturnsAsync(quoteResponse); + + // Act + var result = await _sut.Handle(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + var quotes = await result.Payload.ToListAsync(); + + // Should only return the quote within the requested range (1 day) + quotes.Should().HaveCount(1); + quotes[0].Date.Should().Be(DateTime.Today); + quotes[0].Close.Should().Be(30); + + _brapi.Verify(b => b.GetQuote(query.Ticker, It.IsAny(), It.IsAny()), Times.Once); + + // Give the fire-and-forget task a moment to run + await Task.Delay(100); + _repo.Verify(r => r.AddPrices(It.IsAny>(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenRepositoryThrowsException_ShouldReturnUnexpectedError() + { + // Arrange + var query = new GetQuotesQuery("PETR4", QuoteRange.Day); + var exception = new InvalidOperationException("DB error"); + + _repo.Setup(r => r.GetPrices(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(exception); + + // Act + var result = await _sut.Handle(query, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Messages.Should().Contain(exception.Message); + + _logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => true), + exception, + It.Is>((v, t) => true)), + Times.Once); + } + + [Fact] + public async Task Handle_WhenApiThrowsException_ShouldReturnUnexpectedError() + { + // Arrange + var query = new GetQuotesQuery("PETR4", QuoteRange.Day); + var exception = new InvalidOperationException("API error"); + + // Force fallback to API by returning empty list from DB + _repo.Setup(r => r.GetPrices(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + + _brapi.Setup(b => b.GetQuote(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(exception); + + // Act + var result = await _sut.Handle(query, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Messages.Should().Contain(exception.Message); + + _logger.Verify( + x => x.Log( + LogLevel.Error, + It.IsAny(), + It.Is((v, t) => true), + exception, + It.Is>((v, t) => true)), + Times.Once); + } +} + +// Helper extension for converting Unix time +public static class DateTimeExtensions +{ + public static long ToUnixTimeSeconds(this DateTime dateTime) + { + return new DateTimeOffset(dateTime.ToUniversalTime()).ToUnixTimeSeconds(); + } +} \ No newline at end of file diff --git a/tests/MarketData.UnitTests/Kairos.MarketData.UnitTests.csproj b/tests/MarketData.UnitTests/Kairos.MarketData.UnitTests.csproj new file mode 100644 index 0000000..43cd4f0 --- /dev/null +++ b/tests/MarketData.UnitTests/Kairos.MarketData.UnitTests.csproj @@ -0,0 +1,38 @@ + + + + enable + enable + Exe + Kairos.MarketData.UnitTests + net8.0 + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/MarketData.UnitTests/xunit.runner.json b/tests/MarketData.UnitTests/xunit.runner.json new file mode 100644 index 0000000..249d815 --- /dev/null +++ b/tests/MarketData.UnitTests/xunit.runner.json @@ -0,0 +1,3 @@ +{ + "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json" +} From be9047319fef814623efe9f4a84183b9a8a16d96 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sat, 29 Nov 2025 10:52:16 -0300 Subject: [PATCH 15/34] Fixing unit tests --- src/Gateway/Dockerfile | 1 + src/Gateway/Modules/MarketDataModule.cs | 6 +-- .../UseCases/GetQuotesUseCaseTests.cs | 49 +++++++++++++------ 3 files changed, 38 insertions(+), 18 deletions(-) diff --git a/src/Gateway/Dockerfile b/src/Gateway/Dockerfile index 7a5b508..ed2954f 100644 --- a/src/Gateway/Dockerfile +++ b/src/Gateway/Dockerfile @@ -10,6 +10,7 @@ COPY ["src/Account/Kairos.Account.csproj", "src/Account/"] COPY ["src/MarketData/Kairos.MarketData.csproj", "src/MarketData/"] COPY ["src/Shared/Kairos.Shared.csproj", "src/Shared/"] COPY ["tests/Account.UnitTests/Kairos.Account.UnitTests.csproj", "tests/Account.UnitTests/"] +COPY ["tests/MarketData.UnitTests/Kairos.MarketData.UnitTests.csproj", "tests/MarketData.UnitTests/"] RUN dotnet restore "Kairos.sln" diff --git a/src/Gateway/Modules/MarketDataModule.cs b/src/Gateway/Modules/MarketDataModule.cs index 0444bfb..c0305f8 100644 --- a/src/Gateway/Modules/MarketDataModule.cs +++ b/src/Gateway/Modules/MarketDataModule.cs @@ -10,7 +10,7 @@ public sealed class MarketDataModule : CarterModule { readonly IMediator _mediator; - public MarketDataModule(IMediator mediator) : base("/api/v1/market-data") + public MarketDataModule(IMediator mediator) : base("/api/v1/stocks") { WithTags("MarketData"); @@ -21,12 +21,12 @@ public override void AddRoutes(IEndpointRouteBuilder app) { app .MapGet( - "/stocks", + "/", ([FromQuery] string[] search) => _mediator.Send(new GetStocksQuery(search))) .WithDescription("Get basic information about the specified stock(s)"); app.MapGet( - "/stocks/{ticker}/quote", + "/{ticker}/quote", ( IMediator mediator, [FromRoute] string ticker, diff --git a/tests/MarketData.UnitTests/Business/UseCases/GetQuotesUseCaseTests.cs b/tests/MarketData.UnitTests/Business/UseCases/GetQuotesUseCaseTests.cs index e189e7b..7d979cb 100644 --- a/tests/MarketData.UnitTests/Business/UseCases/GetQuotesUseCaseTests.cs +++ b/tests/MarketData.UnitTests/Business/UseCases/GetQuotesUseCaseTests.cs @@ -1,8 +1,3 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using AutoFixture; using FluentAssertions; using Kairos.MarketData.Business.UseCases; @@ -11,8 +6,6 @@ using Kairos.Shared.Contracts.MarketData.GetStockQuotes; using Microsoft.Extensions.Logging; using Moq; -using Xunit; -using Output = Kairos.Shared.Contracts.Output>; namespace Kairos.MarketData.UnitTests.Business.UseCases; @@ -55,7 +48,12 @@ public async Task Handle_WhenDatabaseHasUpToDatePrices_ShouldReturnPricesFromDat quotes.Should().HaveCount(1); quotes[0].Close.Should().Be(30); - _brapi.Verify(b => b.GetQuote(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _brapi.Verify(b => + b.GetQuote( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); } [Fact] @@ -69,8 +67,20 @@ public async Task Handle_WhenDatabaseHasOutdatedPrices_ShouldFetchFromApiAndRetu }; var apiQuotes = new List { - new() { Date = DateTime.Today.ToUnixTimeSeconds(), Close = 30, AdjustedClose = 30 }, - new() { Date = DateTime.Today.AddDays(-10).ToUnixTimeSeconds(), Close = 25, AdjustedClose = 25 } + new() + { + Date = DateTime.Today.ToUnixTimeSeconds(), + Close = 30, + AdjustedClose = 30, + High = null, Low = null, Open = null, Volume = 0, + }, + new() + { + Date = DateTime.Today.AddDays(-10).ToUnixTimeSeconds(), + Close = 30, + AdjustedClose = 30, + High = null, Low = null, Open = null, Volume = 0, + } }; var quoteResponse = _fixture.Build() .With(r => r.Results, [_fixture.Build().With(qr => qr.HistoricalDataPrice, apiQuotes).Create()]) @@ -86,7 +96,7 @@ public async Task Handle_WhenDatabaseHasOutdatedPrices_ShouldFetchFromApiAndRetu // Assert result.IsSuccess.Should().BeTrue(); - var quotes = await result.Payload.ToListAsync(); + var quotes = await result.Value.ToListAsync(); // Should only return the quote within the requested range (1 day) quotes.Should().HaveCount(1); @@ -97,7 +107,9 @@ public async Task Handle_WhenDatabaseHasOutdatedPrices_ShouldFetchFromApiAndRetu // Give the fire-and-forget task a moment to run await Task.Delay(100); - _repo.Verify(r => r.AddPrices(It.IsAny>(), It.IsAny()), Times.Once); + _repo.Verify(r => r.AddPrices( + It.IsAny>(), + It.IsAny()), Times.Once); } [Fact] @@ -107,8 +119,12 @@ public async Task Handle_WhenRepositoryThrowsException_ShouldReturnUnexpectedErr var query = new GetQuotesQuery("PETR4", QuoteRange.Day); var exception = new InvalidOperationException("DB error"); - _repo.Setup(r => r.GetPrices(It.IsAny(), It.IsAny(), It.IsAny())) - .ThrowsAsync(exception); + _repo + .Setup(r => r.GetPrices( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Throws(exception); // Act var result = await _sut.Handle(query, CancellationToken.None); @@ -135,7 +151,10 @@ public async Task Handle_WhenApiThrowsException_ShouldReturnUnexpectedError() var exception = new InvalidOperationException("API error"); // Force fallback to API by returning empty list from DB - _repo.Setup(r => r.GetPrices(It.IsAny(), It.IsAny(), It.IsAny())) + _repo.Setup(r => r.GetPrices( + It.IsAny(), + It.IsAny(), + It.IsAny())) .Returns(AsyncEnumerable.Empty()); _brapi.Setup(b => b.GetQuote(It.IsAny(), It.IsAny(), It.IsAny())) From e7ee66cd8d446c4492025c741703715b99b3104f Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sat, 29 Nov 2025 11:14:05 -0300 Subject: [PATCH 16/34] Post-merge fix --- src/MarketData/Infra/IBrapi.cs | 16 ---------------- 1 file changed, 16 deletions(-) delete mode 100644 src/MarketData/Infra/IBrapi.cs diff --git a/src/MarketData/Infra/IBrapi.cs b/src/MarketData/Infra/IBrapi.cs deleted file mode 100644 index e479501..0000000 --- a/src/MarketData/Infra/IBrapi.cs +++ /dev/null @@ -1,16 +0,0 @@ -using Kairos.MarketData.Infra.Dtos; -using Refit; - -namespace Kairos.MarketData.Infra; - -/// -/// Open API reference for brapi.dev: https://brapi.dev/swagger/latest.json -/// -internal interface IBrapi -{ - [Get("/quote/{ticker}")] - public Task GetQuote( - string ticker, - [Query] string range = "3mo", - [Query] string interval = "1d"); -} \ No newline at end of file From f7fa573c60cb0a58233b5fc3c36e0bf3183bb0a8 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sun, 30 Nov 2025 14:26:17 -0300 Subject: [PATCH 17/34] Stock Searching --- src/Gateway/Modules/MarketDataModule.cs | 2 +- .../Business/Extensions/StockExtensions.cs | 25 ++++++ .../Business/UseCases/SearchStocksUseCase.cs | 86 +++++++++++++++++++ src/MarketData/Infra/Abstractions/IBrapi.cs | 5 ++ .../Infra/Abstractions/IStockRepository.cs | 11 ++- .../Infra/Dtos/StockSearchResponse.cs | 18 ++++ src/MarketData/Infra/StockRepository.cs | 47 ++++++++-- .../Contracts/MarketData/GetStocksQuery.cs | 13 --- .../SearchStocks/SearchStocksQuery.cs | 14 +++ .../MarketData/SearchStocks/Stock.cs | 11 +++ 10 files changed, 210 insertions(+), 22 deletions(-) create mode 100644 src/MarketData/Business/Extensions/StockExtensions.cs create mode 100644 src/MarketData/Business/UseCases/SearchStocksUseCase.cs create mode 100644 src/MarketData/Infra/Dtos/StockSearchResponse.cs delete mode 100644 src/Shared/Contracts/MarketData/GetStocksQuery.cs create mode 100644 src/Shared/Contracts/MarketData/SearchStocks/SearchStocksQuery.cs create mode 100644 src/Shared/Contracts/MarketData/SearchStocks/Stock.cs diff --git a/src/Gateway/Modules/MarketDataModule.cs b/src/Gateway/Modules/MarketDataModule.cs index c0305f8..8797fa3 100644 --- a/src/Gateway/Modules/MarketDataModule.cs +++ b/src/Gateway/Modules/MarketDataModule.cs @@ -22,7 +22,7 @@ public override void AddRoutes(IEndpointRouteBuilder app) app .MapGet( "/", - ([FromQuery] string[] search) => _mediator.Send(new GetStocksQuery(search))) + ([FromQuery] string[] search) => _mediator.Send(new SearchStocksQuery(search))) .WithDescription("Get basic information about the specified stock(s)"); app.MapGet( diff --git a/src/MarketData/Business/Extensions/StockExtensions.cs b/src/MarketData/Business/Extensions/StockExtensions.cs new file mode 100644 index 0000000..f130f10 --- /dev/null +++ b/src/MarketData/Business/Extensions/StockExtensions.cs @@ -0,0 +1,25 @@ +using System; +using Kairos.MarketData.Infra.Dtos; +using Kairos.Shared.Contracts.MarketData.SearchStocks; + +namespace Kairos.MarketData.Business.Extensions; + +public static class StockExtensions +{ + internal static async IAsyncEnumerable Stream( + this IEnumerable stocks) + { + foreach (var stock in stocks) + { + yield return new Stock( + stock.Stock, + stock.Name, + stock.Close, + stock.Change, + stock.MarketCap, + new Uri(stock.Logo), + stock.Sector + ); + } + } +} diff --git a/src/MarketData/Business/UseCases/SearchStocksUseCase.cs b/src/MarketData/Business/UseCases/SearchStocksUseCase.cs new file mode 100644 index 0000000..5f8fb54 --- /dev/null +++ b/src/MarketData/Business/UseCases/SearchStocksUseCase.cs @@ -0,0 +1,86 @@ +using Kairos.MarketData.Business.Extensions; +using Kairos.MarketData.Infra.Abstractions; +using Kairos.MarketData.Infra.Dtos; +using Kairos.Shared.Contracts; +using Kairos.Shared.Contracts.MarketData; +using Kairos.Shared.Contracts.MarketData.SearchStocks; +using MediatR; +using Microsoft.Extensions.Logging; +using Output = Kairos.Shared.Contracts.Output>; + +namespace Kairos.MarketData.Business.UseCases; + +internal sealed class SearchStocksUseCase( + IBrapi brapi, + IStockRepository stockRepo, + ILogger logger +) : IRequestHandler +{ + public async Task>> Handle( + SearchStocksQuery input, + CancellationToken cancellationToken) + { + var terms = input.Search; + + try + { + var stocks = stockRepo.GetStocks(terms, cancellationToken); + + var isCached = await stocks + .GetAsyncEnumerator(cancellationToken) + .MoveNextAsync(); + + if (isCached is false) + { + var res = await brapi.GetStocks(); + + if (res.Stocks.Length == 0) + { + return Output.Empty; + } + + Task.Run(async () => await CacheStocks(res.Stocks)); + + var filteredStocks = res.Stocks + .Stream() + .Where(s => terms.Any(t => + s.Ticker.Contains(t, StringComparison.OrdinalIgnoreCase) || + s.Name.Contains(t, StringComparison.OrdinalIgnoreCase) || + s.Sector.Contains(t, StringComparison.OrdinalIgnoreCase))); + + return Output.Ok(filteredStocks); + } + + return Output.Ok(stocks); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while retrieving stocks. Input: {@Input}", input); + return Output.UnexpectedError([ex.Message]); + } + } + + async Task CacheStocks(StockSummary[] stocks) + { + const string method = nameof(CacheStocks); + + try + { + logger.LogInformation( + "[{Method}] Upserting stocks into cache. Quantity: {StockQuantity}", + method, + stocks.Length); + + var stocksToCache = await stocks.Stream().ToListAsync(); + + await stockRepo.UpsertStocks(stocksToCache, CancellationToken.None); + } + catch (Exception ex) + { + logger.LogInformation( + ex, + "[{Method}] An unexpected error occurred", + method); + } + } +} diff --git a/src/MarketData/Infra/Abstractions/IBrapi.cs b/src/MarketData/Infra/Abstractions/IBrapi.cs index 753f46a..c33fe0f 100644 --- a/src/MarketData/Infra/Abstractions/IBrapi.cs +++ b/src/MarketData/Infra/Abstractions/IBrapi.cs @@ -13,4 +13,9 @@ public Task GetQuote( string ticker, [Query] string range = "3mo", [Query] string interval = "1d"); + + [Get("/quote/list")] + public Task GetStocks( + [Query] string sortBy = "change", + [Query] string sortOrder = "desc"); } \ No newline at end of file diff --git a/src/MarketData/Infra/Abstractions/IStockRepository.cs b/src/MarketData/Infra/Abstractions/IStockRepository.cs index 45285fe..1476992 100644 --- a/src/MarketData/Infra/Abstractions/IStockRepository.cs +++ b/src/MarketData/Infra/Abstractions/IStockRepository.cs @@ -1,15 +1,20 @@ -using System.Runtime.CompilerServices; using Kairos.MarketData.Infra.Dtos; -using Kairos.Shared.Contracts.MarketData.GetStockQuotes; +using Kairos.Shared.Contracts.MarketData.SearchStocks; namespace Kairos.MarketData.Infra.Abstractions; internal interface IStockRepository { + IAsyncEnumerable GetStocks( + IEnumerable searchTerms, + CancellationToken ct); + + Task UpsertStocks(IEnumerable stocks, CancellationToken ct); + IAsyncEnumerable GetPrices( string ticker, DateTime from, - [EnumeratorCancellation] CancellationToken ct); + CancellationToken ct); Task AddPrices(IEnumerable prices, CancellationToken ct); } diff --git a/src/MarketData/Infra/Dtos/StockSearchResponse.cs b/src/MarketData/Infra/Dtos/StockSearchResponse.cs new file mode 100644 index 0000000..ca44abf --- /dev/null +++ b/src/MarketData/Infra/Dtos/StockSearchResponse.cs @@ -0,0 +1,18 @@ +using System.Text.Json.Serialization; + +namespace Kairos.MarketData.Infra.Dtos; + +public sealed record StockSearchResponse(StockSummary[] Stocks); + +public sealed class StockSummary +{ + public required string Stock { get; init; } + public required string Name { get; init; } + public decimal Close { get; init; } + public double Change { get; init; } + public decimal Volume { get; init; } + [JsonPropertyName("market_cap")] + public ulong MarketCap { get; init; } + public required string Logo { get; init; } + public required string Sector { get; init; } +}; \ No newline at end of file diff --git a/src/MarketData/Infra/StockRepository.cs b/src/MarketData/Infra/StockRepository.cs index 0286866..10d554f 100644 --- a/src/MarketData/Infra/StockRepository.cs +++ b/src/MarketData/Infra/StockRepository.cs @@ -1,15 +1,54 @@ using System.Runtime.CompilerServices; using Kairos.MarketData.Infra.Abstractions; using Kairos.MarketData.Infra.Dtos; +using Kairos.Shared.Contracts.MarketData.SearchStocks; +using MongoDB.Bson; using MongoDB.Driver; namespace Kairos.MarketData.Infra; internal sealed class StockRepository(IMongoDatabase db) : IStockRepository { - readonly IMongoCollection _stocks = db.GetCollection("Stock"); + readonly IMongoCollection _stocks = db.GetCollection("Stock"); readonly IMongoCollection _prices = db.GetCollection("Price"); + public async IAsyncEnumerable GetStocks( + IEnumerable searchTerms, + [EnumeratorCancellation] CancellationToken ct) + { + var filters = searchTerms.Select(term => + { + var regex = new BsonRegularExpression(term, "i"); + return Builders.Filter.Or( + Builders.Filter.Regex(stock => stock.Ticker, regex), + Builders.Filter.Regex(stock => stock.Name, regex), + Builders.Filter.Regex(stock => stock.Sector, regex) + ); + }); + + using var stocks = await _stocks.FindAsync( + Builders.Filter.Or(filters), + new FindOptions + { + Projection = Builders.Projection.Exclude("_id") + }, + ct); + + while (await stocks.MoveNextAsync(cancellationToken: ct)) + { + foreach (var stock in stocks.Current) + { + yield return stock; + } + } + } + + public Task UpsertStocks(IEnumerable stocks, CancellationToken ct) => + _stocks.InsertManyAsync( + stocks, + new InsertManyOptions() { IsOrdered = false }, + ct); + public async IAsyncEnumerable GetPrices( string ticker, DateTime from, @@ -37,11 +76,9 @@ public async IAsyncEnumerable GetPrices( } } - public Task AddPrices(IEnumerable prices, CancellationToken ct) - { - return _prices.InsertManyAsync( + public Task AddPrices(IEnumerable prices, CancellationToken ct) => + _prices.InsertManyAsync( prices, new InsertManyOptions() { IsOrdered = false }, ct); - } } diff --git a/src/Shared/Contracts/MarketData/GetStocksQuery.cs b/src/Shared/Contracts/MarketData/GetStocksQuery.cs deleted file mode 100644 index 22cefe1..0000000 --- a/src/Shared/Contracts/MarketData/GetStocksQuery.cs +++ /dev/null @@ -1,13 +0,0 @@ -using Kairos.Shared.Abstractions; - -namespace Kairos.Shared.Contracts.MarketData; - -public sealed record GetStocksQuery( - Guid CorrelationId, - IEnumerable SearchTerms -) : IQuery -{ - public GetStocksQuery(IEnumerable searchTerms) : this(Guid.NewGuid(), searchTerms) - { - } -} \ No newline at end of file diff --git a/src/Shared/Contracts/MarketData/SearchStocks/SearchStocksQuery.cs b/src/Shared/Contracts/MarketData/SearchStocks/SearchStocksQuery.cs new file mode 100644 index 0000000..3bc6265 --- /dev/null +++ b/src/Shared/Contracts/MarketData/SearchStocks/SearchStocksQuery.cs @@ -0,0 +1,14 @@ +using Kairos.Shared.Abstractions; +using Kairos.Shared.Contracts.MarketData.SearchStocks; + +namespace Kairos.Shared.Contracts.MarketData; + +public sealed record SearchStocksQuery( + Guid CorrelationId, + IEnumerable Search +) : IQuery> +{ + public SearchStocksQuery(IEnumerable searchTerms) : this(Guid.NewGuid(), searchTerms) + { + } +} \ No newline at end of file diff --git a/src/Shared/Contracts/MarketData/SearchStocks/Stock.cs b/src/Shared/Contracts/MarketData/SearchStocks/Stock.cs new file mode 100644 index 0000000..48b76b7 --- /dev/null +++ b/src/Shared/Contracts/MarketData/SearchStocks/Stock.cs @@ -0,0 +1,11 @@ +namespace Kairos.Shared.Contracts.MarketData.SearchStocks; + +public sealed record Stock( + string Ticker, + string Name, + decimal Price, + double DailyYield, + decimal MarketCap, + Uri Logo, + string Sector +); \ No newline at end of file From d9446f8ab59460b34c0c31686d3ee5a06510b01d Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sun, 30 Nov 2025 14:45:31 -0300 Subject: [PATCH 18/34] Idempotency on stock caching --- .../Business/UseCases/SearchStocksUseCase.cs | 2 +- src/MarketData/DependencyInjection.cs | 7 +++++++ .../Infra/Abstractions/IStockRepository.cs | 2 +- src/MarketData/Infra/StockRepository.cs | 17 +++++++++++++---- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/src/MarketData/Business/UseCases/SearchStocksUseCase.cs b/src/MarketData/Business/UseCases/SearchStocksUseCase.cs index 5f8fb54..2183917 100644 --- a/src/MarketData/Business/UseCases/SearchStocksUseCase.cs +++ b/src/MarketData/Business/UseCases/SearchStocksUseCase.cs @@ -71,7 +71,7 @@ async Task CacheStocks(StockSummary[] stocks) method, stocks.Length); - var stocksToCache = await stocks.Stream().ToListAsync(); + var stocksToCache = await stocks.Stream().ToArrayAsync(); await stockRepo.UpsertStocks(stocksToCache, CancellationToken.None); } diff --git a/src/MarketData/DependencyInjection.cs b/src/MarketData/DependencyInjection.cs index cd5a76e..b836dc0 100644 --- a/src/MarketData/DependencyInjection.cs +++ b/src/MarketData/DependencyInjection.cs @@ -3,6 +3,7 @@ using Kairos.MarketData.Configuration; using Kairos.MarketData.Infra; using Kairos.MarketData.Infra.Abstractions; +using Kairos.Shared.Contracts.MarketData.SearchStocks; using Kairos.Shared.Infra.HttpClient; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -129,6 +130,12 @@ async static Task AddDatabase( await db.CreateCollectionAsync(priceCollection, options); } + + await db + .GetCollection("Stock") + .Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Ascending(s => s.Ticker), + new CreateIndexOptions { Unique = true })); return services .AddSingleton(); diff --git a/src/MarketData/Infra/Abstractions/IStockRepository.cs b/src/MarketData/Infra/Abstractions/IStockRepository.cs index 1476992..5aabbc5 100644 --- a/src/MarketData/Infra/Abstractions/IStockRepository.cs +++ b/src/MarketData/Infra/Abstractions/IStockRepository.cs @@ -9,7 +9,7 @@ IAsyncEnumerable GetStocks( IEnumerable searchTerms, CancellationToken ct); - Task UpsertStocks(IEnumerable stocks, CancellationToken ct); + Task UpsertStocks(Stock[] stocks, CancellationToken ct); IAsyncEnumerable GetPrices( string ticker, diff --git a/src/MarketData/Infra/StockRepository.cs b/src/MarketData/Infra/StockRepository.cs index 10d554f..e2236a9 100644 --- a/src/MarketData/Infra/StockRepository.cs +++ b/src/MarketData/Infra/StockRepository.cs @@ -43,11 +43,20 @@ public async IAsyncEnumerable GetStocks( } } - public Task UpsertStocks(IEnumerable stocks, CancellationToken ct) => - _stocks.InsertManyAsync( - stocks, - new InsertManyOptions() { IsOrdered = false }, + public Task UpsertStocks(Stock[] stocks, CancellationToken ct) + { + var writes = stocks.Select(stock => + { + var filter = Builders.Filter.Eq(s => s.Ticker, stock.Ticker); + + return new ReplaceOneModel(filter, stock) { IsUpsert = true }; + }); + + return _stocks.BulkWriteAsync( + writes, + new BulkWriteOptions { IsOrdered = false }, ct); + } public async IAsyncEnumerable GetPrices( string ticker, From 97965d25328646f05f2d27f80e45eea017c7d724 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sun, 30 Nov 2025 15:35:49 -0300 Subject: [PATCH 19/34] Segregating Price and Stock repositories --- .../Business/Extensions/StockExtensions.cs | 1 + .../Business/UseCases/GetQuotesUseCase.cs | 23 +++++----- .../Business/UseCases/SearchStocksUseCase.cs | 6 +-- src/MarketData/DependencyInjection.cs | 3 +- .../Infra/Abstractions/IPriceRepository.cs | 20 +++++++++ .../Infra/Abstractions/IStockRepository.cs | 12 +---- src/MarketData/Infra/Dtos/Price.cs | 2 +- src/MarketData/Infra/PriceRepository.cs | 44 +++++++++++++++++++ src/MarketData/Infra/StockRepository.cs | 38 +--------------- .../MarketData/SearchStocks/Stock.cs | 1 + 10 files changed, 88 insertions(+), 62 deletions(-) create mode 100644 src/MarketData/Infra/Abstractions/IPriceRepository.cs create mode 100644 src/MarketData/Infra/PriceRepository.cs diff --git a/src/MarketData/Business/Extensions/StockExtensions.cs b/src/MarketData/Business/Extensions/StockExtensions.cs index f130f10..99892c6 100644 --- a/src/MarketData/Business/Extensions/StockExtensions.cs +++ b/src/MarketData/Business/Extensions/StockExtensions.cs @@ -17,6 +17,7 @@ internal static async IAsyncEnumerable Stream( stock.Close, stock.Change, stock.MarketCap, + stock.Volume, new Uri(stock.Logo), stock.Sector ); diff --git a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs index 58c6bcd..9380231 100644 --- a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs +++ b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs @@ -3,7 +3,6 @@ using Kairos.MarketData.Infra.Dtos; using Kairos.Shared.Contracts.MarketData.GetStockQuotes; using Kairos.Shared.Extensions; -using MassTransit.Internals; using MediatR; using Microsoft.Extensions.Logging; using MongoDB.Driver.Linq; @@ -14,7 +13,7 @@ namespace Kairos.MarketData.Business.UseCases; internal sealed class GetQuotesUseCase( IBrapi brapi, ILogger logger, - IStockRepository repo) + IPriceRepository repo) : IRequestHandler { public async Task Handle( @@ -26,8 +25,8 @@ public async Task Handle( var range = input.Range.GetCompatibleRange(input.Ticker); var prices = await repo - .GetPrices(input.Ticker, range.GetMinDate(), cancellationToken) - .ToListAsync(); + .Get(input.Ticker, range.GetMinDate(), cancellationToken) + .ToListAsync(cancellationToken); var historicalPriceUpToDate = prices .Any(p => p.Date >= DateTime.Today.AddDays(-3)); @@ -54,7 +53,7 @@ async Task GetUpToDatePrices(GetQuotesQuery input, QuoteRange range) List quotes = quoteRes.Results[0].HistoricalDataPrice; - Task.Run(async () => await SyncPriceData(quotes, input.Ticker)); + Task.Run(async () => await CachePrices(quotes, input.Ticker)); var pricesInsideRange = quotes .ToStreamedQuote() @@ -63,9 +62,9 @@ async Task GetUpToDatePrices(GetQuotesQuery input, QuoteRange range) return Output.Ok(pricesInsideRange); } - async Task SyncPriceData(List quotes, string ticker) + async Task CachePrices(List quotes, string ticker) { - const string method = nameof(SyncPriceData); + const string method = nameof(CachePrices); if (quotes.Count == 0) { @@ -74,15 +73,17 @@ async Task SyncPriceData(List quotes, string ticker) try { + var prices = quotes.ToPrices(ticker).ToList(); + logger.LogInformation( "[{Method}] Synchronizing {Ticker} prices from {FromDate} to {ToDate}", method, ticker, - quotes.MinBy(q => q.Date)!.Date, - quotes.MaxBy(q => q.Date)!.Date); + prices!.MinBy(q => q.Date)!.Date, + prices!.MaxBy(q => q.Date)!.Date); - await repo.AddPrices(quotes.ToPrices(ticker), CancellationToken.None); - } + await repo.Append(prices, CancellationToken.None); + } catch (Exception ex) { logger.LogInformation( diff --git a/src/MarketData/Business/UseCases/SearchStocksUseCase.cs b/src/MarketData/Business/UseCases/SearchStocksUseCase.cs index 2183917..fdd9deb 100644 --- a/src/MarketData/Business/UseCases/SearchStocksUseCase.cs +++ b/src/MarketData/Business/UseCases/SearchStocksUseCase.cs @@ -12,7 +12,7 @@ namespace Kairos.MarketData.Business.UseCases; internal sealed class SearchStocksUseCase( IBrapi brapi, - IStockRepository stockRepo, + IStockRepository repo, ILogger logger ) : IRequestHandler { @@ -24,7 +24,7 @@ public async Task>> Handle( try { - var stocks = stockRepo.GetStocks(terms, cancellationToken); + var stocks = repo.Get(terms, cancellationToken); var isCached = await stocks .GetAsyncEnumerator(cancellationToken) @@ -73,7 +73,7 @@ async Task CacheStocks(StockSummary[] stocks) var stocksToCache = await stocks.Stream().ToArrayAsync(); - await stockRepo.UpsertStocks(stocksToCache, CancellationToken.None); + await repo.Upsert(stocksToCache, CancellationToken.None); } catch (Exception ex) { diff --git a/src/MarketData/DependencyInjection.cs b/src/MarketData/DependencyInjection.cs index b836dc0..0842722 100644 --- a/src/MarketData/DependencyInjection.cs +++ b/src/MarketData/DependencyInjection.cs @@ -138,6 +138,7 @@ await db new CreateIndexOptions { Unique = true })); return services - .AddSingleton(); + .AddSingleton() + .AddSingleton(); } } diff --git a/src/MarketData/Infra/Abstractions/IPriceRepository.cs b/src/MarketData/Infra/Abstractions/IPriceRepository.cs new file mode 100644 index 0000000..7bce85f --- /dev/null +++ b/src/MarketData/Infra/Abstractions/IPriceRepository.cs @@ -0,0 +1,20 @@ +using Kairos.MarketData.Infra.Dtos; + +namespace Kairos.MarketData.Infra.Abstractions; + +internal interface IPriceRepository +{ + IAsyncEnumerable Get( + string ticker, + DateTime from, + CancellationToken ct); + + /// + /// Append a collection of prices into the database + /// + /// The already existent prices are gonna be ignored + /// + /// + /// + Task Append(IEnumerable prices, CancellationToken ct); +} diff --git a/src/MarketData/Infra/Abstractions/IStockRepository.cs b/src/MarketData/Infra/Abstractions/IStockRepository.cs index 5aabbc5..f0bf6c9 100644 --- a/src/MarketData/Infra/Abstractions/IStockRepository.cs +++ b/src/MarketData/Infra/Abstractions/IStockRepository.cs @@ -1,20 +1,12 @@ -using Kairos.MarketData.Infra.Dtos; using Kairos.Shared.Contracts.MarketData.SearchStocks; namespace Kairos.MarketData.Infra.Abstractions; internal interface IStockRepository { - IAsyncEnumerable GetStocks( + IAsyncEnumerable Get( IEnumerable searchTerms, CancellationToken ct); - Task UpsertStocks(Stock[] stocks, CancellationToken ct); - - IAsyncEnumerable GetPrices( - string ticker, - DateTime from, - CancellationToken ct); - - Task AddPrices(IEnumerable prices, CancellationToken ct); + Task Upsert(Stock[] stocks, CancellationToken ct); } diff --git a/src/MarketData/Infra/Dtos/Price.cs b/src/MarketData/Infra/Dtos/Price.cs index 3a54519..121165e 100644 --- a/src/MarketData/Infra/Dtos/Price.cs +++ b/src/MarketData/Infra/Dtos/Price.cs @@ -14,5 +14,5 @@ public Price(string ticker, long unixTimeSeconds, decimal value, decimal adjuste value, adjustedValue, DateTime.UtcNow - ) { } + ) { } } \ No newline at end of file diff --git a/src/MarketData/Infra/PriceRepository.cs b/src/MarketData/Infra/PriceRepository.cs new file mode 100644 index 0000000..c7f6b8f --- /dev/null +++ b/src/MarketData/Infra/PriceRepository.cs @@ -0,0 +1,44 @@ +using System.Runtime.CompilerServices; +using Kairos.MarketData.Infra.Abstractions; +using Kairos.MarketData.Infra.Dtos; +using MongoDB.Driver; + +namespace Kairos.MarketData.Infra; + +internal sealed class PriceRepository(IMongoDatabase db) : IPriceRepository +{ + readonly IMongoCollection _prices = db.GetCollection("Price"); + + public async IAsyncEnumerable Get( + string ticker, + DateTime from, + [EnumeratorCancellation] CancellationToken ct) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(x => x.Ticker, ticker), + Builders.Filter.Gte(x => x.Date, from), + Builders.Filter.Lte(x => x.Date, DateTime.Today) + ); + + var options = new FindOptions + { + Projection = Builders.Projection.Exclude("_id") + }; + + using var prices = await _prices.FindAsync(filter, options, ct); + + while (await prices.MoveNextAsync(cancellationToken: ct)) + { + foreach (var price in prices.Current) + { + yield return price; + } + } + } + + public Task Append(IEnumerable prices, CancellationToken ct) => + _prices.InsertManyAsync( + prices, + new InsertManyOptions() { IsOrdered = false }, + ct); +} diff --git a/src/MarketData/Infra/StockRepository.cs b/src/MarketData/Infra/StockRepository.cs index e2236a9..37f6b3a 100644 --- a/src/MarketData/Infra/StockRepository.cs +++ b/src/MarketData/Infra/StockRepository.cs @@ -10,9 +10,8 @@ namespace Kairos.MarketData.Infra; internal sealed class StockRepository(IMongoDatabase db) : IStockRepository { readonly IMongoCollection _stocks = db.GetCollection("Stock"); - readonly IMongoCollection _prices = db.GetCollection("Price"); - public async IAsyncEnumerable GetStocks( + public async IAsyncEnumerable Get( IEnumerable searchTerms, [EnumeratorCancellation] CancellationToken ct) { @@ -43,7 +42,7 @@ public async IAsyncEnumerable GetStocks( } } - public Task UpsertStocks(Stock[] stocks, CancellationToken ct) + public Task Upsert(Stock[] stocks, CancellationToken ct) { var writes = stocks.Select(stock => { @@ -57,37 +56,4 @@ public Task UpsertStocks(Stock[] stocks, CancellationToken ct) new BulkWriteOptions { IsOrdered = false }, ct); } - - public async IAsyncEnumerable GetPrices( - string ticker, - DateTime from, - [EnumeratorCancellation] CancellationToken ct) - { - var filter = Builders.Filter.And( - Builders.Filter.Eq(x => x.Ticker, ticker), - Builders.Filter.Gte(x => x.Date, from), - Builders.Filter.Lte(x => x.Date, DateTime.Today) - ); - - var options = new FindOptions - { - Projection = Builders.Projection.Exclude("_id") - }; - - using var prices = await _prices.FindAsync(filter, options, ct); - - while (await prices.MoveNextAsync(cancellationToken: ct)) - { - foreach (var price in prices.Current) - { - yield return price; - } - } - } - - public Task AddPrices(IEnumerable prices, CancellationToken ct) => - _prices.InsertManyAsync( - prices, - new InsertManyOptions() { IsOrdered = false }, - ct); } diff --git a/src/Shared/Contracts/MarketData/SearchStocks/Stock.cs b/src/Shared/Contracts/MarketData/SearchStocks/Stock.cs index 48b76b7..875ba60 100644 --- a/src/Shared/Contracts/MarketData/SearchStocks/Stock.cs +++ b/src/Shared/Contracts/MarketData/SearchStocks/Stock.cs @@ -6,6 +6,7 @@ public sealed record Stock( decimal Price, double DailyYield, decimal MarketCap, + decimal TradeVolume, Uri Logo, string Sector ); \ No newline at end of file From 21372e58d1a634c8d4cfaa1f5a671732d86363a3 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sun, 30 Nov 2025 16:51:23 -0300 Subject: [PATCH 20/34] Idempotency on prices appending --- .../Business/UseCases/GetQuotesUseCase.cs | 6 +++--- .../Infra/Abstractions/IPriceRepository.cs | 8 ++++++-- src/MarketData/Infra/PriceRepository.cs | 19 +++++++++++++++---- src/MarketData/Infra/StockRepository.cs | 1 - 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs index 9380231..22bdf9c 100644 --- a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs +++ b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs @@ -73,16 +73,16 @@ async Task CachePrices(List quotes, string ticker) try { - var prices = quotes.ToPrices(ticker).ToList(); + var prices = quotes.ToPrices(ticker).ToArray(); logger.LogInformation( - "[{Method}] Synchronizing {Ticker} prices from {FromDate} to {ToDate}", + "[{Method}] Synchronizing {Ticker} prices from {FromDate:dd-MM-yyyy} to {ToDate:dd-MM-yyyy}", method, ticker, prices!.MinBy(q => q.Date)!.Date, prices!.MaxBy(q => q.Date)!.Date); - await repo.Append(prices, CancellationToken.None); + await repo.Append(ticker, prices, CancellationToken.None); } catch (Exception ex) { diff --git a/src/MarketData/Infra/Abstractions/IPriceRepository.cs b/src/MarketData/Infra/Abstractions/IPriceRepository.cs index 7bce85f..b8d9396 100644 --- a/src/MarketData/Infra/Abstractions/IPriceRepository.cs +++ b/src/MarketData/Infra/Abstractions/IPriceRepository.cs @@ -12,9 +12,13 @@ IAsyncEnumerable Get( /// /// Append a collection of prices into the database /// - /// The already existent prices are gonna be ignored + /// The existent prices (Ticker, Date) are gonna be ignored + /// /// /// /// - Task Append(IEnumerable prices, CancellationToken ct); + Task Append( + string ticker, + Price[] prices, + CancellationToken ct); } diff --git a/src/MarketData/Infra/PriceRepository.cs b/src/MarketData/Infra/PriceRepository.cs index c7f6b8f..4e8f237 100644 --- a/src/MarketData/Infra/PriceRepository.cs +++ b/src/MarketData/Infra/PriceRepository.cs @@ -36,9 +36,20 @@ public async IAsyncEnumerable Get( } } - public Task Append(IEnumerable prices, CancellationToken ct) => - _prices.InsertManyAsync( - prices, - new InsertManyOptions() { IsOrdered = false }, + public async Task Append( + string ticker, + Price[] prices, + CancellationToken ct) + { + var maxDate = (await _prices + .Find(p => p.Ticker == ticker) + .SortByDescending(p => p.Date) + .FirstOrDefaultAsync(ct)) + ?.Date ?? DateTime.MinValue; + + await _prices.InsertManyAsync( + prices.Where(p => p.Date > maxDate), + new InsertManyOptions() { IsOrdered = false }, ct); + } } diff --git a/src/MarketData/Infra/StockRepository.cs b/src/MarketData/Infra/StockRepository.cs index 37f6b3a..baa4fce 100644 --- a/src/MarketData/Infra/StockRepository.cs +++ b/src/MarketData/Infra/StockRepository.cs @@ -1,6 +1,5 @@ using System.Runtime.CompilerServices; using Kairos.MarketData.Infra.Abstractions; -using Kairos.MarketData.Infra.Dtos; using Kairos.Shared.Contracts.MarketData.SearchStocks; using MongoDB.Bson; using MongoDB.Driver; From 742ade0175cea0ce661061ab6203fc6d56edb6cd Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sun, 30 Nov 2025 18:49:01 -0300 Subject: [PATCH 21/34] Stock cache TTL --- src/Gateway/Modules/MarketDataModule.cs | 33 +++++++++-- .../Business/Extensions/StockExtensions.cs | 4 +- .../Business/UseCases/SearchStocksUseCase.cs | 4 +- src/MarketData/DependencyInjection.cs | 56 +++++++++++-------- .../Infra/Abstractions/IStockRepository.cs | 2 +- .../Infra/Dtos/StockSearchResponse.cs | 15 +---- src/MarketData/Infra/Dtos/StockSummary.cs | 17 ++++++ src/MarketData/Infra/PriceRepository.cs | 9 ++- src/MarketData/Infra/StockRepository.cs | 16 ++++-- .../SearchStocks/SearchStocksQuery.cs | 13 ++++- .../MarketData/SearchStocks/Stock.cs | 7 ++- 11 files changed, 120 insertions(+), 56 deletions(-) create mode 100644 src/MarketData/Infra/Dtos/StockSummary.cs diff --git a/src/Gateway/Modules/MarketDataModule.cs b/src/Gateway/Modules/MarketDataModule.cs index 8797fa3..7507219 100644 --- a/src/Gateway/Modules/MarketDataModule.cs +++ b/src/Gateway/Modules/MarketDataModule.cs @@ -1,8 +1,11 @@ using Carter; +using Kairos.Gateway.Filters; using Kairos.Shared.Contracts.MarketData; using Kairos.Shared.Contracts.MarketData.GetStockQuotes; +using Kairos.Shared.Contracts.MarketData.SearchStocks; using MediatR; using Microsoft.AspNetCore.Mvc; +using Microsoft.OpenApi.Any; namespace Kairos.Gateway.Modules; @@ -21,9 +24,25 @@ public override void AddRoutes(IEndpointRouteBuilder app) { app .MapGet( - "/", - ([FromQuery] string[] search) => _mediator.Send(new SearchStocksQuery(search))) - .WithDescription("Get basic information about the specified stock(s)"); + "/search", + ([FromQuery(Name = "q")] string[] query, + [FromQuery] byte? page = null, + [FromQuery] byte? limit = null) => + _mediator.Send(new SearchStocksQuery(query, page, limit)) + ) + .WithSummary("Search for stocks") + .WithDescription("Searches for stocks by ticker, name or sector based on a list of search terms.") + .WithOpenApi(operation => + { + var query = operation.Parameters.First(p => p.Name == "q"); + query.Description = "A list of terms to search for. Can be partial tickers, names or sectors."; + query.Required = true; + query.Example = new OpenApiArray { new OpenApiString("banco"), new OpenApiString("xpbr31") }; + return operation; + }) + .Produces>>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status204NoContent, typeof(object)) + .Produces>(StatusCodes.Status500InternalServerError); app.MapGet( "/{ticker}/quote", @@ -31,7 +50,11 @@ public override void AddRoutes(IEndpointRouteBuilder app) IMediator mediator, [FromRoute] string ticker, [FromQuery] QuoteRange? range = null) => - _mediator.Send(new GetQuotesQuery(ticker, range))) - .WithDescription("Get a stock's historical quotes.\n\n Tickers for test: PETR4, MGLU3, VALE3 and ITUB4 "); + _mediator.Send(new GetQuotesQuery(ticker, range)) + ) + .WithSummary("Get historical quotes") + .WithDescription("Tickers for test: PETR4, MGLU3, VALE3 and ITUB4 ") + .Produces>>(StatusCodes.Status200OK) + .Produces>(StatusCodes.Status500InternalServerError); } } \ No newline at end of file diff --git a/src/MarketData/Business/Extensions/StockExtensions.cs b/src/MarketData/Business/Extensions/StockExtensions.cs index 99892c6..390a2d4 100644 --- a/src/MarketData/Business/Extensions/StockExtensions.cs +++ b/src/MarketData/Business/Extensions/StockExtensions.cs @@ -16,8 +16,8 @@ internal static async IAsyncEnumerable Stream( stock.Name, stock.Close, stock.Change, - stock.MarketCap, - stock.Volume, + stock.MarketCap ?? 0, + stock.Volume ?? 0, new Uri(stock.Logo), stock.Sector ); diff --git a/src/MarketData/Business/UseCases/SearchStocksUseCase.cs b/src/MarketData/Business/UseCases/SearchStocksUseCase.cs index fdd9deb..07640f2 100644 --- a/src/MarketData/Business/UseCases/SearchStocksUseCase.cs +++ b/src/MarketData/Business/UseCases/SearchStocksUseCase.cs @@ -20,11 +20,11 @@ public async Task>> Handle( SearchStocksQuery input, CancellationToken cancellationToken) { - var terms = input.Search; + var terms = input.Query; try { - var stocks = repo.Get(terms, cancellationToken); + var stocks = repo.GetByTickerOrNameOrSector(terms, cancellationToken); var isCached = await stocks .GetAsyncEnumerator(cancellationToken) diff --git a/src/MarketData/DependencyInjection.cs b/src/MarketData/DependencyInjection.cs index 0842722..7a089ed 100644 --- a/src/MarketData/DependencyInjection.cs +++ b/src/MarketData/DependencyInjection.cs @@ -3,6 +3,7 @@ using Kairos.MarketData.Configuration; using Kairos.MarketData.Infra; using Kairos.MarketData.Infra.Abstractions; +using Kairos.MarketData.Infra.Dtos; using Kairos.Shared.Contracts.MarketData.SearchStocks; using Kairos.Shared.Infra.HttpClient; using Microsoft.Extensions.Configuration; @@ -10,6 +11,7 @@ using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Options; using MongoDB.Bson; +using MongoDB.Bson.Serialization.Conventions; using MongoDB.Driver; using Polly; using Polly.Contrib.WaitAndRetry; @@ -93,6 +95,11 @@ async static Task AddDatabase( this IServiceCollection services, IConfigurationManager config) { + ConventionRegistry.Register( + "camelCase", + new ConventionPack { new CamelCaseElementNameConvention() }, + t => true); + services.Configure(config.GetSection("Database")); services.AddSingleton(sp => @@ -100,45 +107,50 @@ async static Task AddDatabase( var settings = services.BuildServiceProvider() .GetRequiredService>() .Value; + var connString = settings.MarketData.ConnectionString; - var marketDataDb = MongoUrl.Create(connString).DatabaseName; - - return new MongoClient(connString).GetDatabase(marketDataDb); + return new MongoClient(connString).GetDatabase("MarketData"); }); var db = services .BuildServiceProvider() .GetRequiredService(); - const string priceCollection = "Price"; + await CreateDbIfNotExists(db); + + return services + .AddSingleton() + .AddSingleton(); + } - var collections = await db.ListCollectionsAsync(new ListCollectionsOptions - { - Filter = new BsonDocument("name", priceCollection) + static async Task CreateDbIfNotExists(IMongoDatabase db) + { + var collections = await db.ListCollectionsAsync(new ListCollectionsOptions + { + Filter = Builders.Filter.Or( + new BsonDocument("name", nameof(Price)), + new BsonDocument("name", nameof(Stock)) + ) }); if (await collections.AnyAsync() is false) { - var options = new CreateCollectionOptions + await db.CreateCollectionAsync("Price", new CreateCollectionOptions { TimeSeriesOptions = new TimeSeriesOptions( - timeField: "Date", - metaField: "Ticker", + timeField: nameof(Price.Date).ToLowerInvariant(), + metaField: nameof(Price.Ticker).ToLowerInvariant(), granularity: TimeSeriesGranularity.Seconds) - }; + }); - await db.CreateCollectionAsync(priceCollection, options); - } - - await db - .GetCollection("Stock") - .Indexes.CreateOneAsync(new CreateIndexModel( - Builders.IndexKeys.Ascending(s => s.Ticker), - new CreateIndexOptions { Unique = true })); + await db.CreateCollectionAsync("Stock"); - return services - .AddSingleton() - .AddSingleton(); + await db + .GetCollection("Stock") + .Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Ascending(s => s.Ticker), + new CreateIndexOptions { Unique = true })); + } } } diff --git a/src/MarketData/Infra/Abstractions/IStockRepository.cs b/src/MarketData/Infra/Abstractions/IStockRepository.cs index f0bf6c9..1c9a5e6 100644 --- a/src/MarketData/Infra/Abstractions/IStockRepository.cs +++ b/src/MarketData/Infra/Abstractions/IStockRepository.cs @@ -4,7 +4,7 @@ namespace Kairos.MarketData.Infra.Abstractions; internal interface IStockRepository { - IAsyncEnumerable Get( + IAsyncEnumerable GetByTickerOrNameOrSector( IEnumerable searchTerms, CancellationToken ct); diff --git a/src/MarketData/Infra/Dtos/StockSearchResponse.cs b/src/MarketData/Infra/Dtos/StockSearchResponse.cs index ca44abf..7016e13 100644 --- a/src/MarketData/Infra/Dtos/StockSearchResponse.cs +++ b/src/MarketData/Infra/Dtos/StockSearchResponse.cs @@ -2,17 +2,4 @@ namespace Kairos.MarketData.Infra.Dtos; -public sealed record StockSearchResponse(StockSummary[] Stocks); - -public sealed class StockSummary -{ - public required string Stock { get; init; } - public required string Name { get; init; } - public decimal Close { get; init; } - public double Change { get; init; } - public decimal Volume { get; init; } - [JsonPropertyName("market_cap")] - public ulong MarketCap { get; init; } - public required string Logo { get; init; } - public required string Sector { get; init; } -}; \ No newline at end of file +public sealed record StockSearchResponse(StockSummary[] Stocks); \ No newline at end of file diff --git a/src/MarketData/Infra/Dtos/StockSummary.cs b/src/MarketData/Infra/Dtos/StockSummary.cs new file mode 100644 index 0000000..6e8243a --- /dev/null +++ b/src/MarketData/Infra/Dtos/StockSummary.cs @@ -0,0 +1,17 @@ +using System; +using System.Text.Json.Serialization; + +namespace Kairos.MarketData.Infra.Dtos; + +public sealed class StockSummary +{ + public required string Stock { get; init; } + public required string Name { get; init; } + public decimal Close { get; init; } + public decimal Change { get; init; } + public decimal? Volume { get; init; } + [JsonPropertyName("market_cap")] + public decimal? MarketCap { get; init; } + public required string Logo { get; init; } + public required string Sector { get; init; } +}; \ No newline at end of file diff --git a/src/MarketData/Infra/PriceRepository.cs b/src/MarketData/Infra/PriceRepository.cs index 4e8f237..e396b20 100644 --- a/src/MarketData/Infra/PriceRepository.cs +++ b/src/MarketData/Infra/PriceRepository.cs @@ -47,8 +47,15 @@ public async Task Append( .FirstOrDefaultAsync(ct)) ?.Date ?? DateTime.MinValue; + var insertable = prices.Where(p => p.Date > maxDate); + + if (insertable.Any() is false) + { + return; + } + await _prices.InsertManyAsync( - prices.Where(p => p.Date > maxDate), + insertable, new InsertManyOptions() { IsOrdered = false }, ct); } diff --git a/src/MarketData/Infra/StockRepository.cs b/src/MarketData/Infra/StockRepository.cs index baa4fce..93ef7a6 100644 --- a/src/MarketData/Infra/StockRepository.cs +++ b/src/MarketData/Infra/StockRepository.cs @@ -9,18 +9,24 @@ namespace Kairos.MarketData.Infra; internal sealed class StockRepository(IMongoDatabase db) : IStockRepository { readonly IMongoCollection _stocks = db.GetCollection("Stock"); + readonly static TimeSpan _cacheTtl = TimeSpan.FromHours(1); - public async IAsyncEnumerable Get( + public async IAsyncEnumerable GetByTickerOrNameOrSector( IEnumerable searchTerms, [EnumeratorCancellation] CancellationToken ct) { + var minDate = DateTime.UtcNow.Add(-_cacheTtl); + var filters = searchTerms.Select(term => { var regex = new BsonRegularExpression(term, "i"); - return Builders.Filter.Or( - Builders.Filter.Regex(stock => stock.Ticker, regex), - Builders.Filter.Regex(stock => stock.Name, regex), - Builders.Filter.Regex(stock => stock.Sector, regex) + return Builders.Filter.And( + Builders.Filter.Gte(s => s.UpdatedAt, minDate), + Builders.Filter.Or( + Builders.Filter.Regex(stock => stock.Ticker, regex), + Builders.Filter.Regex(stock => stock.Name, regex), + Builders.Filter.Regex(stock => stock.Sector, regex) + ) ); }); diff --git a/src/Shared/Contracts/MarketData/SearchStocks/SearchStocksQuery.cs b/src/Shared/Contracts/MarketData/SearchStocks/SearchStocksQuery.cs index 3bc6265..05e2668 100644 --- a/src/Shared/Contracts/MarketData/SearchStocks/SearchStocksQuery.cs +++ b/src/Shared/Contracts/MarketData/SearchStocks/SearchStocksQuery.cs @@ -5,10 +5,19 @@ namespace Kairos.Shared.Contracts.MarketData; public sealed record SearchStocksQuery( Guid CorrelationId, - IEnumerable Search + IEnumerable Query, + int Page, + int Limit ) : IQuery> { - public SearchStocksQuery(IEnumerable searchTerms) : this(Guid.NewGuid(), searchTerms) + public SearchStocksQuery( + IEnumerable searchTerms, + byte? page, + byte? limit) : this( + Guid.NewGuid(), + searchTerms, + page ?? 1, + limit ?? 10) { } } \ No newline at end of file diff --git a/src/Shared/Contracts/MarketData/SearchStocks/Stock.cs b/src/Shared/Contracts/MarketData/SearchStocks/Stock.cs index 875ba60..4bde640 100644 --- a/src/Shared/Contracts/MarketData/SearchStocks/Stock.cs +++ b/src/Shared/Contracts/MarketData/SearchStocks/Stock.cs @@ -4,9 +4,12 @@ public sealed record Stock( string Ticker, string Name, decimal Price, - double DailyYield, + decimal DailyYield, decimal MarketCap, decimal TradeVolume, Uri Logo, string Sector -); \ No newline at end of file +) +{ + public DateTime UpdatedAt { get; private set; } = DateTime.UtcNow; +} \ No newline at end of file From 563661c2f944098f7f89abbd1d151d1d93400e12 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sun, 30 Nov 2025 19:55:42 -0300 Subject: [PATCH 22/34] Pagination --- .../Business/UseCases/OpenAccountUseCase.cs | 2 +- src/Gateway/Filters/ResponseFormatter.cs | 2 +- src/Gateway/Modules/MarketDataModule.cs | 22 +++---- src/Gateway/appsettings.Local.json | 2 +- .../Business/UseCases/GetQuotesUseCase.cs | 5 ++ .../Business/UseCases/SearchStocksUseCase.cs | 8 ++- .../Infra/Abstractions/IStockRepository.cs | 2 + src/MarketData/Infra/StockRepository.cs | 66 ++++++++----------- .../SearchStocks/SearchStocksQuery.cs | 4 +- src/Shared/Contracts/Output.cs | 10 +-- src/Shared/Contracts/OutputStatus.cs | 2 +- 11 files changed, 58 insertions(+), 67 deletions(-) diff --git a/src/Account/Business/UseCases/OpenAccountUseCase.cs b/src/Account/Business/UseCases/OpenAccountUseCase.cs index bf256d6..4c23143 100644 --- a/src/Account/Business/UseCases/OpenAccountUseCase.cs +++ b/src/Account/Business/UseCases/OpenAccountUseCase.cs @@ -44,7 +44,7 @@ await bus.Publish( ctx => ctx.CorrelationId = req.CorrelationId, cancellationToken); - return Output.Ok([ + return Output.Created([ $"Conta de investimento {accountId} aberta!", "Confirme a abertura no e-mail que será enviado em instantes."]); } diff --git a/src/Gateway/Filters/ResponseFormatter.cs b/src/Gateway/Filters/ResponseFormatter.cs index 96ac8b4..9c12436 100644 --- a/src/Gateway/Filters/ResponseFormatter.cs +++ b/src/Gateway/Filters/ResponseFormatter.cs @@ -27,7 +27,7 @@ internal sealed class ResponseFormatter(ILogger logger) : IEn OutputStatus.Created => StatusCodes.Status201Created, OutputStatus.Empty => StatusCodes.Status204NoContent, OutputStatus.InvalidInput => StatusCodes.Status400BadRequest, - OutputStatus.UnexistentId => StatusCodes.Status404NotFound, + OutputStatus.NotFound => StatusCodes.Status404NotFound, OutputStatus.BusinessLogicViolation => StatusCodes.Status422UnprocessableEntity, _ => StatusCodes.Status500InternalServerError, }; diff --git a/src/Gateway/Modules/MarketDataModule.cs b/src/Gateway/Modules/MarketDataModule.cs index 7507219..04297b5 100644 --- a/src/Gateway/Modules/MarketDataModule.cs +++ b/src/Gateway/Modules/MarketDataModule.cs @@ -23,13 +23,11 @@ public MarketDataModule(IMediator mediator) : base("/api/v1/stocks") public override void AddRoutes(IEndpointRouteBuilder app) { app - .MapGet( - "/search", - ([FromQuery(Name = "q")] string[] query, - [FromQuery] byte? page = null, - [FromQuery] byte? limit = null) => - _mediator.Send(new SearchStocksQuery(query, page, limit)) - ) + .MapGet("/search", ( + [FromQuery] string[] q, + [FromQuery] int? page = null, + [FromQuery] int? limit = null) => + _mediator.Send(new SearchStocksQuery(q, page, limit))) .WithSummary("Search for stocks") .WithDescription("Searches for stocks by ticker, name or sector based on a list of search terms.") .WithOpenApi(operation => @@ -41,17 +39,13 @@ public override void AddRoutes(IEndpointRouteBuilder app) return operation; }) .Produces>>(StatusCodes.Status200OK) - .Produces(StatusCodes.Status204NoContent, typeof(object)) + .Produces>(StatusCodes.Status404NotFound) .Produces>(StatusCodes.Status500InternalServerError); - app.MapGet( - "/{ticker}/quote", - ( - IMediator mediator, + app.MapGet("/{ticker}/quote", ( [FromRoute] string ticker, [FromQuery] QuoteRange? range = null) => - _mediator.Send(new GetQuotesQuery(ticker, range)) - ) + _mediator.Send(new GetQuotesQuery(ticker, range))) .WithSummary("Get historical quotes") .WithDescription("Tickers for test: PETR4, MGLU3, VALE3 and ITUB4 ") .Produces>>(StatusCodes.Status200OK) diff --git a/src/Gateway/appsettings.Local.json b/src/Gateway/appsettings.Local.json index a382356..dd93aee 100644 --- a/src/Gateway/appsettings.Local.json +++ b/src/Gateway/appsettings.Local.json @@ -12,7 +12,7 @@ { "Name": "Console" } ] }, - "Health": { "Seq": { "Url": "https://capp-kairos-seq.salmonpebble-5905d3a4.eastus2.azurecontainerapps.io:5341/health" } }, + "Health": { "Seq": { "Url": "https://capp-kairos-seq.salmonpebble-5905d3a4.eastus2.azurecontainerapps.io:443/health" } }, "EventBus": { "HostAddress": "rabbitmq://dev:dev@localhost:5672/kairos", "Events": { diff --git a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs index 22bdf9c..1925348 100644 --- a/src/MarketData/Business/UseCases/GetQuotesUseCase.cs +++ b/src/MarketData/Business/UseCases/GetQuotesUseCase.cs @@ -1,3 +1,4 @@ +using System.Net; using Kairos.MarketData.Business.Extensions; using Kairos.MarketData.Infra.Abstractions; using Kairos.MarketData.Infra.Dtos; @@ -38,6 +39,10 @@ public async Task Handle( return Output.Ok(prices.ToStreamedQuote()); } + catch (Refit.ApiException ex) when (ex.StatusCode is HttpStatusCode.NotFound) + { + return Output.NotFound([$"Ativo {input.Ticker} não encontrado"]); + } catch (Exception ex) { logger.LogError(ex, "An error occurred while retrieving quotes. Input: {@Input}", input); diff --git a/src/MarketData/Business/UseCases/SearchStocksUseCase.cs b/src/MarketData/Business/UseCases/SearchStocksUseCase.cs index 07640f2..e40fab8 100644 --- a/src/MarketData/Business/UseCases/SearchStocksUseCase.cs +++ b/src/MarketData/Business/UseCases/SearchStocksUseCase.cs @@ -24,7 +24,11 @@ public async Task>> Handle( try { - var stocks = repo.GetByTickerOrNameOrSector(terms, cancellationToken); + var stocks = repo.GetByTickerOrNameOrSector( + terms, + input.Page, + input.Limit, + cancellationToken); var isCached = await stocks .GetAsyncEnumerator(cancellationToken) @@ -36,7 +40,7 @@ public async Task>> Handle( if (res.Stocks.Length == 0) { - return Output.Empty; + return Output.NotFound(["Ativo não encontrado."]); } Task.Run(async () => await CacheStocks(res.Stocks)); diff --git a/src/MarketData/Infra/Abstractions/IStockRepository.cs b/src/MarketData/Infra/Abstractions/IStockRepository.cs index 1c9a5e6..8d5d87a 100644 --- a/src/MarketData/Infra/Abstractions/IStockRepository.cs +++ b/src/MarketData/Infra/Abstractions/IStockRepository.cs @@ -6,6 +6,8 @@ internal interface IStockRepository { IAsyncEnumerable GetByTickerOrNameOrSector( IEnumerable searchTerms, + int page, + int pageSize, CancellationToken ct); Task Upsert(Stock[] stocks, CancellationToken ct); diff --git a/src/MarketData/Infra/StockRepository.cs b/src/MarketData/Infra/StockRepository.cs index 93ef7a6..d08dfa2 100644 --- a/src/MarketData/Infra/StockRepository.cs +++ b/src/MarketData/Infra/StockRepository.cs @@ -13,52 +13,44 @@ internal sealed class StockRepository(IMongoDatabase db) : IStockRepository public async IAsyncEnumerable GetByTickerOrNameOrSector( IEnumerable searchTerms, + int page, + int pageSize, [EnumeratorCancellation] CancellationToken ct) { var minDate = DateTime.UtcNow.Add(-_cacheTtl); - var filters = searchTerms.Select(term => - { - var regex = new BsonRegularExpression(term, "i"); - return Builders.Filter.And( - Builders.Filter.Gte(s => s.UpdatedAt, minDate), - Builders.Filter.Or( + var cacheTtlFilter = Builders.Filter.Gte(s => s.UpdatedAt, minDate); + + var tickerNameOrSectorFilter = Builders.Filter.Or( + searchTerms.Select(term => + { + var regex = new BsonRegularExpression(term, "i"); + return Builders.Filter.Or( Builders.Filter.Regex(stock => stock.Ticker, regex), Builders.Filter.Regex(stock => stock.Name, regex), Builders.Filter.Regex(stock => stock.Sector, regex) - ) - ); - }); - - using var stocks = await _stocks.FindAsync( - Builders.Filter.Or(filters), - new FindOptions - { - Projection = Builders.Projection.Exclude("_id") - }, - ct); - - while (await stocks.MoveNextAsync(cancellationToken: ct)) - { - foreach (var stock in stocks.Current) - { - yield return stock; - } - } + ); + })); + + var stocks = _stocks + .Find(Builders.Filter.And(cacheTtlFilter, tickerNameOrSectorFilter)) + // .SortByDescending(s => s.DailyYield) + .Skip(pageSize * (page - 1)) + .Limit(pageSize) + .Project(Builders.Projection.Exclude("_id")) + .ToAsyncEnumerable(); + + await foreach (var stock in stocks) yield return stock; } - public Task Upsert(Stock[] stocks, CancellationToken ct) - { - var writes = stocks.Select(stock => - { - var filter = Builders.Filter.Eq(s => s.Ticker, stock.Ticker); - - return new ReplaceOneModel(filter, stock) { IsUpsert = true }; - }); - - return _stocks.BulkWriteAsync( - writes, + public Task Upsert(Stock[] stocks, CancellationToken ct) => + _stocks.BulkWriteAsync( + stocks.Select(stock => + { + var filter = Builders.Filter.Eq(s => s.Ticker, stock.Ticker); + + return new ReplaceOneModel(filter, stock) { IsUpsert = true }; + }), new BulkWriteOptions { IsOrdered = false }, ct); - } } diff --git a/src/Shared/Contracts/MarketData/SearchStocks/SearchStocksQuery.cs b/src/Shared/Contracts/MarketData/SearchStocks/SearchStocksQuery.cs index 05e2668..89939f5 100644 --- a/src/Shared/Contracts/MarketData/SearchStocks/SearchStocksQuery.cs +++ b/src/Shared/Contracts/MarketData/SearchStocks/SearchStocksQuery.cs @@ -12,8 +12,8 @@ int Limit { public SearchStocksQuery( IEnumerable searchTerms, - byte? page, - byte? limit) : this( + int? page, + int? limit) : this( Guid.NewGuid(), searchTerms, page ?? 1, diff --git a/src/Shared/Contracts/Output.cs b/src/Shared/Contracts/Output.cs index 5ba449f..c9e3d88 100644 --- a/src/Shared/Contracts/Output.cs +++ b/src/Shared/Contracts/Output.cs @@ -31,9 +31,6 @@ public Output(Output result) } #region Success - public static Output Ok(IEnumerable? messages = null) => - new(OutputStatus.Ok, messages ?? []); - public static Output Created(IEnumerable? messages = null) => new(OutputStatus.Created, messages ?? []); @@ -47,9 +44,6 @@ public static Output UnexpectedError(IEnumerable messages) => public static Output BusinessLogicViolation(IEnumerable messages) => new(OutputStatus.BusinessLogicViolation, messages); - public static Output UnexistentId(IEnumerable messages) => - new(OutputStatus.UnexistentId, messages); - public static Output InvalidInput(IEnumerable messages) => new(OutputStatus.InvalidInput, messages); #endregion @@ -89,8 +83,8 @@ public static Output Created(TValue value, IEnumerable? messages public static Output InvalidInput(IEnumerable messages, TValue? value = default) => new(value, OutputStatus.InvalidInput, messages); - public static Output UnexistentId(IEnumerable messages, TValue? value = default) => - new(value, OutputStatus.UnexistentId, messages); + public static Output NotFound(IEnumerable messages, TValue? value = default) => + new(value, OutputStatus.NotFound, messages); public static Output BusinessLogicViolation(IEnumerable messages, TValue? value = default) => new(value, OutputStatus.BusinessLogicViolation, messages); diff --git a/src/Shared/Contracts/OutputStatus.cs b/src/Shared/Contracts/OutputStatus.cs index 123c12d..59ae8b1 100644 --- a/src/Shared/Contracts/OutputStatus.cs +++ b/src/Shared/Contracts/OutputStatus.cs @@ -28,7 +28,7 @@ public enum OutputStatus /// /// Non-existent identifier /// - UnexistentId, + NotFound, /// /// Business logic violation From 6e2f92478f7806be170b81356fcb55018ac8abe7 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sun, 30 Nov 2025 20:29:46 -0300 Subject: [PATCH 23/34] Unit tests --- .editorconfig | 5 + .../Business/UseCases/SearchStocksUseCase.cs | 51 +++--- .../UseCases/OpenAccountUseCaseTests.cs | 2 +- .../UseCases/GetQuotesUseCaseTests.cs | 25 +-- .../UseCases/SearchStocksUseCaseTests.cs | 170 ++++++++++++++++++ 5 files changed, 216 insertions(+), 37 deletions(-) create mode 100644 tests/MarketData.UnitTests/Business/UseCases/SearchStocksUseCaseTests.cs diff --git a/.editorconfig b/.editorconfig index ac7d1d8..aa31cf1 100644 --- a/.editorconfig +++ b/.editorconfig @@ -534,5 +534,10 @@ dotnet_diagnostic.CA1707.severity = none # S1125: Remove the unnecessary Boolean literal(s). dotnet_diagnostic.S1125.severity = none +dotnet_diagnostic.xUnit1051.severity = none +dotnet_diagnostic.CS1998.severity = none +dotnet_diagnostic.S2696.severity = none +dotnet_diagnostic.CA1849.severity = none + [**/Migrations/*] generated_code = true \ No newline at end of file diff --git a/src/MarketData/Business/UseCases/SearchStocksUseCase.cs b/src/MarketData/Business/UseCases/SearchStocksUseCase.cs index e40fab8..3c31c3f 100644 --- a/src/MarketData/Business/UseCases/SearchStocksUseCase.cs +++ b/src/MarketData/Business/UseCases/SearchStocksUseCase.cs @@ -20,12 +20,10 @@ public async Task>> Handle( SearchStocksQuery input, CancellationToken cancellationToken) { - var terms = input.Query; - try { var stocks = repo.GetByTickerOrNameOrSector( - terms, + input.Query, input.Page, input.Limit, cancellationToken); @@ -34,28 +32,11 @@ public async Task>> Handle( .GetAsyncEnumerator(cancellationToken) .MoveNextAsync(); - if (isCached is false) + return isCached switch { - var res = await brapi.GetStocks(); - - if (res.Stocks.Length == 0) - { - return Output.NotFound(["Ativo não encontrado."]); - } - - Task.Run(async () => await CacheStocks(res.Stocks)); - - var filteredStocks = res.Stocks - .Stream() - .Where(s => terms.Any(t => - s.Ticker.Contains(t, StringComparison.OrdinalIgnoreCase) || - s.Name.Contains(t, StringComparison.OrdinalIgnoreCase) || - s.Sector.Contains(t, StringComparison.OrdinalIgnoreCase))); - - return Output.Ok(filteredStocks); - } - - return Output.Ok(stocks); + false => await GetUpdatedStocks(input), + _ => Output.Ok(stocks) + }; } catch (Exception ex) { @@ -64,6 +45,28 @@ public async Task>> Handle( } } + async Task>> GetUpdatedStocks(SearchStocksQuery input) + { + var res = await brapi.GetStocks(); + + if (res.Stocks.Length == 0) + { + return Output.NotFound(["Ativo não encontrado."]); + } + + Task.Run(async () => await CacheStocks(res.Stocks)); + + var stocks = res.Stocks.Stream() + .Where(s => input.Query.Any(t => + s.Ticker.Contains(t, StringComparison.OrdinalIgnoreCase) || + s.Name.Contains(t, StringComparison.OrdinalIgnoreCase) || + s.Sector.Contains(t, StringComparison.OrdinalIgnoreCase))) + .Skip(input.Limit * (input.Page - 1)) + .Take(input.Limit); + + return Output.Ok(stocks); + } + async Task CacheStocks(StockSummary[] stocks) { const string method = nameof(CacheStocks); diff --git a/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs b/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs index 42f837a..47996fb 100644 --- a/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs +++ b/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs @@ -35,7 +35,7 @@ public async Task OpenAccount_HappyPath() var output = await _sut.Handle(command, ct); // Assert - output.Status.Should().Be(OutputStatus.Ok); + output.Status.Should().Be(OutputStatus.Created); await _bus.Received().Publish( Arg.Is(e => e.Document == command.Document), diff --git a/tests/MarketData.UnitTests/Business/UseCases/GetQuotesUseCaseTests.cs b/tests/MarketData.UnitTests/Business/UseCases/GetQuotesUseCaseTests.cs index 7d979cb..250411e 100644 --- a/tests/MarketData.UnitTests/Business/UseCases/GetQuotesUseCaseTests.cs +++ b/tests/MarketData.UnitTests/Business/UseCases/GetQuotesUseCaseTests.cs @@ -11,17 +11,17 @@ namespace Kairos.MarketData.UnitTests.Business.UseCases; public sealed class GetQuotesUseCaseTests { - private readonly Fixture _fixture; - private readonly Mock _brapi; - private readonly Mock _repo; - private readonly Mock> _logger; - private readonly GetQuotesUseCase _sut; + readonly Fixture _fixture; + readonly Mock _brapi; + readonly Mock _repo; + readonly Mock> _logger; + readonly GetQuotesUseCase _sut; public GetQuotesUseCaseTests() { _fixture = new Fixture(); _brapi = new Mock(); - _repo = new Mock(); + _repo = new Mock(); _logger = new Mock>(); _sut = new GetQuotesUseCase(_brapi.Object, _logger.Object, _repo.Object); } @@ -36,7 +36,7 @@ public async Task Handle_WhenDatabaseHasUpToDatePrices_ShouldReturnPricesFromDat new("PETR4", DateTime.Today.ToUnixTimeSeconds(), 30, 30) }; - _repo.Setup(r => r.GetPrices(query.Ticker, It.IsAny(), It.IsAny())) + _repo.Setup(r => r.Get(query.Ticker, It.IsAny(), It.IsAny())) .Returns(dbPrices.ToAsyncEnumerable()); // Act @@ -86,7 +86,7 @@ public async Task Handle_WhenDatabaseHasOutdatedPrices_ShouldFetchFromApiAndRetu .With(r => r.Results, [_fixture.Build().With(qr => qr.HistoricalDataPrice, apiQuotes).Create()]) .Create(); - _repo.Setup(r => r.GetPrices(query.Ticker, It.IsAny(), It.IsAny())) + _repo.Setup(r => r.Get(query.Ticker, It.IsAny(), It.IsAny())) .Returns(outdatedDbPrices.ToAsyncEnumerable()); _brapi.Setup(b => b.GetQuote(query.Ticker, It.IsAny(), It.IsAny())) .ReturnsAsync(quoteResponse); @@ -107,8 +107,9 @@ public async Task Handle_WhenDatabaseHasOutdatedPrices_ShouldFetchFromApiAndRetu // Give the fire-and-forget task a moment to run await Task.Delay(100); - _repo.Verify(r => r.AddPrices( - It.IsAny>(), + _repo.Verify(r => r.Append( + It.IsAny(), + It.IsAny(), It.IsAny()), Times.Once); } @@ -120,7 +121,7 @@ public async Task Handle_WhenRepositoryThrowsException_ShouldReturnUnexpectedErr var exception = new InvalidOperationException("DB error"); _repo - .Setup(r => r.GetPrices( + .Setup(r => r.Get( It.IsAny(), It.IsAny(), It.IsAny())) @@ -151,7 +152,7 @@ public async Task Handle_WhenApiThrowsException_ShouldReturnUnexpectedError() var exception = new InvalidOperationException("API error"); // Force fallback to API by returning empty list from DB - _repo.Setup(r => r.GetPrices( + _repo.Setup(r => r.Get( It.IsAny(), It.IsAny(), It.IsAny())) diff --git a/tests/MarketData.UnitTests/Business/UseCases/SearchStocksUseCaseTests.cs b/tests/MarketData.UnitTests/Business/UseCases/SearchStocksUseCaseTests.cs new file mode 100644 index 0000000..c94d81a --- /dev/null +++ b/tests/MarketData.UnitTests/Business/UseCases/SearchStocksUseCaseTests.cs @@ -0,0 +1,170 @@ +using AutoFixture; +using FluentAssertions; +using Kairos.MarketData.Business.UseCases; +using Kairos.MarketData.Infra.Abstractions; +using Kairos.MarketData.Infra.Dtos; +using Kairos.Shared.Contracts.MarketData; +using Kairos.Shared.Contracts.MarketData.SearchStocks; +using Kairos.Shared.Enums; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit.Internal; + +namespace Kairos.MarketData.UnitTests.Business.UseCases; + +public sealed class SearchStocksUseCaseTests +{ + readonly Fixture _fixture; + readonly Mock _brapi; + readonly Mock _repo; + readonly Mock> _logger; + readonly SearchStocksUseCase _sut; + + public SearchStocksUseCaseTests() + { + _fixture = new Fixture(); + _brapi = new Mock(); + _repo = new Mock(); + _logger = new Mock>(); + _sut = new SearchStocksUseCase(_brapi.Object, _repo.Object, _logger.Object); + } + + [Fact] + public async Task Handle_WhenStocksAreInCache_ShouldReturnStocksFromRepository() + { + // Arrange + var query = new SearchStocksQuery(["PETR"], 1, 10); + var cachedStocks = _fixture.CreateMany(5).ToAsyncEnumerable(); + + _repo.Setup(r => r.GetByTickerOrNameOrSector( + query.Query, query.Page, query.Limit, It.IsAny())) + .Returns(cachedStocks); + + // Act + var result = await _sut.Handle(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + var stocks = await result.Value.ToListAsync(); + stocks.Should().HaveCount(5); + + _brapi.Verify(b => b.GetStocks(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Handle_WhenCacheIsEmpty_ShouldFetchFromApiAndReturnPaginatedResults() + { + // Arrange + // We want page 2 with a limit of 1, searching for "banco" + var query = new SearchStocksQuery(["banco"], 2, 1); + var apiStocks = new[] + { + _fixture.Build() + .With(s => s.Name, "Itaú Unibanco") + .With(s => s.Stock, "ITUB4") + .With(s => s.Logo, "https://example.com/itub4.png") + .Create(), + _fixture.Build() + .With(s => s.Name, "Banco Bradesco") + .With(s => s.Stock, "BBDC4") + .With(s => s.Logo, "https://example.com/bbdc4.png") + .Create(), + _fixture.Build() + .With(s => s.Name, "Banco do Brasil") + .With(s => s.Stock, "BBAS3") + .With(s => s.Logo, "https://example.com/bbas3.png") + .Create(), + _fixture.Build() + .With(s => s.Name, "Vale") + .With(s => s.Stock, "VALE3") + .With(s => s.Logo, "https://example.com/vale3.png") + .Create() // Should be filtered out + }; + + var apiResponse = new StockSearchResponse(apiStocks); + + _repo.Setup(r => r.GetByTickerOrNameOrSector( + query.Query, query.Page, query.Limit, It.IsAny())) + .Returns(AsyncEnumerable.Empty()); // Simulate cache miss + + _brapi.Setup(b => b.GetStocks(It.IsAny(), It.IsAny())).ReturnsAsync(apiResponse); + + // Act + var result = await _sut.Handle(query, CancellationToken.None); + + // Assert + result.IsSuccess.Should().BeTrue(); + var stocks = await result.Value.ToListAsync(); + + // Assert correct filtering and pagination (page 2, limit 1 should return the second bank) + stocks.Should().HaveCount(1); + stocks.Single().Ticker.Should().Be("BBDC4"); + + _brapi.Verify(b => b.GetStocks(It.IsAny(), It.IsAny()), Times.Once); + + // Give the background caching task a moment to execute + await Task.Delay(100); + _repo.Verify(r => r.Upsert(It.Is(s => s.Length == 4), It.IsAny()), Times.Once); + } + + [Fact] + public async Task Handle_WhenApiReturnsNoStocks_ShouldReturnNotFound() + { + // Arrange + var query = new SearchStocksQuery(["INVALID"], 1, 10); + var apiResponse = new StockSearchResponse([]); + + _repo.Setup(r => r.GetByTickerOrNameOrSector( + It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + _brapi.Setup(b => b.GetStocks(It.IsAny(), It.IsAny())).ReturnsAsync(apiResponse); + + // Act + var result = await _sut.Handle(query, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Status.Should().Be(OutputStatus.NotFound); + } + + [Fact] + public async Task Handle_WhenRepositoryThrowsException_ShouldReturnUnexpectedError() + { + // Arrange + var query = new SearchStocksQuery(["error"], 1, 10); + var exception = new InvalidOperationException("Database connection failed"); + + _repo.Setup(r => r.GetByTickerOrNameOrSector( + It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Throws(exception); + + // Act + var result = await _sut.Handle(query, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Status.Should().Be(OutputStatus.UnexpectedError); + result.Messages.Should().Contain(exception.Message); + } + + [Fact] + public async Task Handle_WhenApiThrowsException_ShouldReturnUnexpectedError() + { + // Arrange + var query = new SearchStocksQuery(["error"], 1, 10); + var exception = new InvalidOperationException("API is down"); + + _repo.Setup(r => r.GetByTickerOrNameOrSector( + It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + _brapi.Setup(b => b.GetStocks(It.IsAny(), It.IsAny())).ThrowsAsync(exception); + + // Act + var result = await _sut.Handle(query, CancellationToken.None); + + // Assert + result.IsFailure.Should().BeTrue(); + result.Status.Should().Be(OutputStatus.UnexpectedError); + result.Messages.Should().Contain(exception.Message); + } +} \ No newline at end of file From aaa43380387faca83ffaf5d2bd5d9ffd803f5b5e Mon Sep 17 00:00:00 2001 From: sagustavo Date: Sun, 30 Nov 2025 21:09:52 -0300 Subject: [PATCH 24/34] Managed identity fix --- .github/capp-kairos-broker.yml | 17 +++++++++++++++++ src/Gateway/Filters/ResponseFormatter.cs | 8 ++------ src/Gateway/Program.cs | 4 ++-- src/Gateway/appsettings.Staging.json | 8 +++++++- src/MarketData/DependencyInjection.cs | 8 ++++---- src/Shared/Configuration/KeyVaultOptions.cs | 2 +- src/Shared/DependencyInjection.cs | 5 ++--- 7 files changed, 35 insertions(+), 17 deletions(-) diff --git a/.github/capp-kairos-broker.yml b/.github/capp-kairos-broker.yml index 214826d..83f0841 100644 --- a/.github/capp-kairos-broker.yml +++ b/.github/capp-kairos-broker.yml @@ -15,6 +15,17 @@ properties: targetPort: 8080 allowInsecure: true + secrets: + - name: azure-tenant-id + keyVaultUrl: https://kv-kairos.vault.azure.net/secrets/sp-tenant-id + identity: "/subscriptions/28b743cd-805d-4451-ba1f-067df11cbafc/resourcegroups/kairos/providers/Microsoft.ManagedIdentity/userAssignedIdentities/kairos-service" + - name: azure-client-id + keyVaultUrl: https://kv-kairos.vault.azure.net/secrets/sp-client-id + identity: "/subscriptions/28b743cd-805d-4451-ba1f-067df11cbafc/resourcegroups/kairos/providers/Microsoft.ManagedIdentity/userAssignedIdentities/kairos-service" + - name: azure-client-secret + keyVaultUrl: https://kv-kairos.vault.azure.net/secrets/sp-client-secret + identity: "/subscriptions/28b743cd-805d-4451-ba1f-067df11cbafc/resourcegroups/kairos/providers/Microsoft.ManagedIdentity/userAssignedIdentities/kairos-service" + registries: - server: kairosfinance.azurecr.io identity: "/subscriptions/28b743cd-805d-4451-ba1f-067df11cbafc/resourcegroups/kairos/providers/Microsoft.ManagedIdentity/userAssignedIdentities/kairos-service" @@ -42,6 +53,12 @@ properties: env: - name: ASPNETCORE_ENVIRONMENT value: Staging + - name: AZURE_TENANT_ID + secretRef: azure-tenant-id + - name: AZURE_CLIENT_ID + secretRef: azure-client-id + - name: AZURE_CLIENT_SECRET + secretRef: azure-client-secret scale: minReplicas: 0 diff --git a/src/Gateway/Filters/ResponseFormatter.cs b/src/Gateway/Filters/ResponseFormatter.cs index 9c12436..264d895 100644 --- a/src/Gateway/Filters/ResponseFormatter.cs +++ b/src/Gateway/Filters/ResponseFormatter.cs @@ -43,14 +43,10 @@ internal sealed class ResponseFormatter(ILogger logger) : IEn } catch (Exception ex) { - var message = ex is OperationCanceledException - ? "Oops! The process has taken too long" - : "Oops! An unexpected error occurred."; - - logger.LogError(ex, "{Error}", message); + logger.LogError(ex, "{Error}", ex.Message); return Results.Json( - data: new Response(null, [message]), + data: new Response(null, [ex.Message]), statusCode: StatusCodes.Status500InternalServerError ); } diff --git a/src/Gateway/Program.cs b/src/Gateway/Program.cs index 54b4103..5f3ca4c 100644 --- a/src/Gateway/Program.cs +++ b/src/Gateway/Program.cs @@ -21,9 +21,9 @@ .AddShared( builder.Configuration, builder.Host) + .AddMarketData(builder.Configuration) .AddGateway(builder.Configuration) - .AddAccount(builder.Configuration) - .AddMarketData(builder.Configuration); + .AddAccount(builder.Configuration); } WebApplication app = builder.Build(); diff --git a/src/Gateway/appsettings.Staging.json b/src/Gateway/appsettings.Staging.json index b5c6b1a..c301242 100644 --- a/src/Gateway/appsettings.Staging.json +++ b/src/Gateway/appsettings.Staging.json @@ -1,6 +1,6 @@ { "Serilog": { - "Using": [ "Serilog.Sinks.Seq" ], + "Using": [ "Serilog.Sinks.Seq", "Serilog.Sinks.Console" ], "WriteTo": [ { "Name": "Seq", @@ -8,6 +8,12 @@ "serverUrl": "https://capp-kairos-seq.salmonpebble-5905d3a4.eastus2.azurecontainerapps.io:5341", "apiKey": "Serilog:WriteTo:0:Args:apiKey" } + }, + { + "Name": "Console", + "Args": { + "outputTemplate": "({ThreadId}) [{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} - CorrelationId: {CorrelationId} - Hostname: {MachineName} - Logger: {SourceContext} {NewLine}{Exception}" + } } ] }, diff --git a/src/MarketData/DependencyInjection.cs b/src/MarketData/DependencyInjection.cs index 7a089ed..b8ac059 100644 --- a/src/MarketData/DependencyInjection.cs +++ b/src/MarketData/DependencyInjection.cs @@ -21,7 +21,7 @@ namespace Kairos.MarketData; public static class DependencyInjection { - public static async Task AddMarketData( + public static IServiceCollection AddMarketData( this IServiceCollection services, IConfigurationManager config) { @@ -34,13 +34,13 @@ public static async Task AddMarketData( services.AddDatabase(config).Wait(); return services - .AddApiClients(api) - .AddHealthCheck() .AddMediatR(cfg => { cfg.LicenseKey = config["Keys:MediatR"]; cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); - }); + }) + .AddApiClients(api) + .AddHealthCheck(); } static IServiceCollection AddApiClients(this IServiceCollection services, Settings.Api api) diff --git a/src/Shared/Configuration/KeyVaultOptions.cs b/src/Shared/Configuration/KeyVaultOptions.cs index 70404ad..9530689 100644 --- a/src/Shared/Configuration/KeyVaultOptions.cs +++ b/src/Shared/Configuration/KeyVaultOptions.cs @@ -11,7 +11,7 @@ public sealed class KeyVaultOptions public required string Url { get; init; } /* - * The following props are required when running locally + * The following props might be required when running locally * and must be set via .NET Secret Manager */ public string? TenantId { get; init; } diff --git a/src/Shared/DependencyInjection.cs b/src/Shared/DependencyInjection.cs index 252a6a6..2c90043 100644 --- a/src/Shared/DependencyInjection.cs +++ b/src/Shared/DependencyInjection.cs @@ -35,9 +35,8 @@ static IServiceCollection AddKeyVault( KeyVaultOptions keyVault = GetKeyVault(config); config.AddAzureKeyVault( - new Uri(keyVault.Url), - new DefaultAzureCredential() - ); + new Uri(keyVault.Url), + new DefaultAzureCredential()); return services; } From 1598c75c78e7f8003e429c23bfe163ba4b8634a8 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Mon, 1 Dec 2025 00:14:45 -0300 Subject: [PATCH 25/34] Fix in-memory stock filtering when Sector null --- src/MarketData/Business/Extensions/StockExtensions.cs | 2 +- src/MarketData/Infra/Dtos/StockSummary.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/MarketData/Business/Extensions/StockExtensions.cs b/src/MarketData/Business/Extensions/StockExtensions.cs index 390a2d4..9294fc0 100644 --- a/src/MarketData/Business/Extensions/StockExtensions.cs +++ b/src/MarketData/Business/Extensions/StockExtensions.cs @@ -19,7 +19,7 @@ internal static async IAsyncEnumerable Stream( stock.MarketCap ?? 0, stock.Volume ?? 0, new Uri(stock.Logo), - stock.Sector + stock.Sector ?? string.Empty ); } } diff --git a/src/MarketData/Infra/Dtos/StockSummary.cs b/src/MarketData/Infra/Dtos/StockSummary.cs index 6e8243a..ebfbeb0 100644 --- a/src/MarketData/Infra/Dtos/StockSummary.cs +++ b/src/MarketData/Infra/Dtos/StockSummary.cs @@ -13,5 +13,5 @@ public sealed class StockSummary [JsonPropertyName("market_cap")] public decimal? MarketCap { get; init; } public required string Logo { get; init; } - public required string Sector { get; init; } + public string? Sector { get; init; } }; \ No newline at end of file From 46ac619e09371b7dfcff3ab778f48ff50e688512 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Mon, 1 Dec 2025 12:00:29 -0300 Subject: [PATCH 26/34] Account creation --- Directory.Packages.props | 2 + .../Business/UseCases/OpenAccountUseCase.cs | 83 +++++++++++++++---- src/Account/DependencyInjection.cs | 44 ++++++++-- .../Domain/Abstraction/KairosAccount.cs | 5 ++ src/Account/Domain/Enum/Gender.cs | 9 ++ src/Account/Domain/Enum/PersonType.cs | 14 ++++ src/Account/Domain/Investor.cs | 79 ++++++++++++++++++ src/Account/Infra/AccountContext.cs | 25 ++++++ src/Account/Kairos.Account.csproj | 4 + src/Gateway/Filters/ResponseFormatter.cs | 2 +- src/Gateway/Modules/AccountModule.cs | 15 ++-- src/Shared/Abstractions/Domain/ValueObject.cs | 52 ++++++++++++ src/Shared/Contracts/Account/AccountOpened.cs | 10 ++- .../{OpenAccount.cs => OpenAccountCommand.cs} | 6 +- .../Contracts/Account/ValueObjects/Email.cs | 37 +++++++++ src/Shared/Contracts/Output.cs | 8 +- src/Shared/Contracts/OutputStatus.cs | 2 +- .../UseCases/OpenAccountUseCaseTests.cs | 2 +- 18 files changed, 357 insertions(+), 42 deletions(-) create mode 100644 src/Account/Domain/Abstraction/KairosAccount.cs create mode 100644 src/Account/Domain/Enum/Gender.cs create mode 100644 src/Account/Domain/Enum/PersonType.cs create mode 100644 src/Account/Domain/Investor.cs create mode 100644 src/Account/Infra/AccountContext.cs create mode 100644 src/Shared/Abstractions/Domain/ValueObject.cs rename src/Shared/Contracts/Account/{OpenAccount.cs => OpenAccountCommand.cs} (68%) create mode 100644 src/Shared/Contracts/Account/ValueObjects/Email.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index a52cc85..ac60234 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,8 @@ + + diff --git a/src/Account/Business/UseCases/OpenAccountUseCase.cs b/src/Account/Business/UseCases/OpenAccountUseCase.cs index 4c23143..fba1c60 100644 --- a/src/Account/Business/UseCases/OpenAccountUseCase.cs +++ b/src/Account/Business/UseCases/OpenAccountUseCase.cs @@ -1,22 +1,31 @@ +using System.Data.Common; +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 logger, - IBus bus -) : IRequestHandler + IBus bus, + UserManager identity, + AccountContext db +) : IRequestHandler { - public async Task Handle(OpenAccount req, CancellationToken cancellationToken) + public async Task Handle( + OpenAccountCommand req, + CancellationToken cancellationToken) { var enrichers = new Dictionary { ["CorrelationId"] = req.CorrelationId, - ["Document"] = req.Document + ["Email"] = req.Email, }; using (logger.BeginScope(enrichers)) @@ -25,27 +34,69 @@ public async Task Handle(OpenAccount req, CancellationToken cancellation { logger.LogInformation("Starting account opening process"); - await Task.Delay(3000, cancellationToken); + var alreadyExists = 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); + // TODO: também validar se existe conta com o mesmo phoneNumber or document + Investor? existingAccount = await identity.FindByEmailAsync(req.Email); - logger.LogInformation("Account {AccountId} created", accountId); + if (existingAccount is not null) + { + logger.LogWarning("Email already in use."); + return Output.PolicyViolation(["O e-mail fornecido já está em uso."]); + } - AccountOpened @event = new( - Id: accountId, - FirstName: req.FirstName, - LastName: req.LastName, - Document: req.Document, - Email: req.Email, - Birthdate: req.Birthdate); + var openAccountResult = Investor.OpenAccount( + req.Name, + req.Document, + req.PhoneNumber, + req.Email, + req.Birthdate, + req.AcceptTerms + ); + + if (openAccountResult.IsFailure) + { + return new Output(openAccountResult); + } + + 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); + } + + var token = await identity.GenerateEmailConfirmationTokenAsync(investor); + + logger.LogInformation("Account {AccountId} opened!", investor.Id); await bus.Publish( - @event, + new AccountOpened( + investor.Id, + req.Name, + req.PhoneNumber, + req.Document, + req.Email, + req.Birthdate, + Uri.EscapeDataString(token), + req.CorrelationId), ctx => ctx.CorrelationId = req.CorrelationId, cancellationToken); 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) diff --git a/src/Account/DependencyInjection.cs b/src/Account/DependencyInjection.cs index f098d3d..c434b2f 100644 --- a/src/Account/DependencyInjection.cs +++ b/src/Account/DependencyInjection.cs @@ -1,7 +1,12 @@ using System.Reflection; +using Kairos.Account.Domain; +using Kairos.Account.Infra; using Kairos.Account.Infra.Consumers; using Kairos.Shared.Contracts.Account; +using Kairos.Shared.Infra; using MassTransit; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; @@ -11,13 +16,15 @@ public static class DependencyInjection { public static IServiceCollection AddAccount( this IServiceCollection services, - IConfiguration config) + IConfigurationManager config) { - return services.AddMediatR(cfg => - { - cfg.LicenseKey = config["Keys:MediatR"]; - cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); - }); + return services + .AddIdentity(config) + .AddMediatR(cfg => + { + cfg.LicenseKey = config["Keys:MediatR"]; + cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); + }); } public static IBusRegistrationConfigurator AddAccountConsumers(this IBusRegistrationConfigurator x) @@ -46,4 +53,29 @@ public static IRabbitMqBusFactoryConfigurator ConfigureAccountEndpoints( return cfg; } + + static IServiceCollection AddIdentity( + this IServiceCollection services, + IConfigurationManager config) + { + services + .AddDbContext(o => o.UseSqlServer(config["Database:Broker:ConnectionString"]!)) + .AddIdentity(o => + { + o.Password.RequireDigit = true; + o.Password.RequiredLength = 6; + + o.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); + o.Lockout.MaxFailedAccessAttempts = 5; + o.Lockout.AllowedForNewUsers = true; + + o.SignIn.RequireConfirmedEmail = true; + o.Tokens.EmailConfirmationTokenProvider = "Default"; + o.User.RequireUniqueEmail = true; + }) + .AddEntityFrameworkStores() + .AddDefaultTokenProviders(); + + return services; + } } diff --git a/src/Account/Domain/Abstraction/KairosAccount.cs b/src/Account/Domain/Abstraction/KairosAccount.cs new file mode 100644 index 0000000..d0f8a69 --- /dev/null +++ b/src/Account/Domain/Abstraction/KairosAccount.cs @@ -0,0 +1,5 @@ +using Microsoft.AspNetCore.Identity; + +namespace Kairos.Account.Domain.Abstraction; + +public abstract class KairosAccount : IdentityUser; diff --git a/src/Account/Domain/Enum/Gender.cs b/src/Account/Domain/Enum/Gender.cs new file mode 100644 index 0000000..ebb7633 --- /dev/null +++ b/src/Account/Domain/Enum/Gender.cs @@ -0,0 +1,9 @@ +namespace Kairos.Account.Domain.Enum; + +public enum Gender +{ + Unspecified = 0, + Male, + Female, + Other +} diff --git a/src/Account/Domain/Enum/PersonType.cs b/src/Account/Domain/Enum/PersonType.cs new file mode 100644 index 0000000..7d98ca5 --- /dev/null +++ b/src/Account/Domain/Enum/PersonType.cs @@ -0,0 +1,14 @@ +namespace Kairos.Account.Domain.Enum; + +public enum PersonType +{ + /// + /// Individual + /// + Natural = 1, + + /// + /// Corporate + /// + Legal +} diff --git a/src/Account/Domain/Investor.cs b/src/Account/Domain/Investor.cs new file mode 100644 index 0000000..bdb2b63 --- /dev/null +++ b/src/Account/Domain/Investor.cs @@ -0,0 +1,79 @@ +using Kairos.Account.Domain.Abstraction; +using Kairos.Account.Domain.Enum; +using Kairos.Shared.Contracts; + +namespace Kairos.Account.Domain; + +/// +/// Investment account +/// +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 PersonType Type { get; private set; } + + Investor( + string name, + string phoneNumber, + string document, + string email, + DateTime birthdate + ) + { + Name = name; + PhoneNumber = phoneNumber; + Document = document; + Email = email; + Birthdate = birthdate; + Type = document.Length == 11 ? PersonType.Natural : PersonType.Legal; + } + + public static Output OpenAccount( + string name, + string document, + string phoneNumber, + string email, + DateTime birthdate, + bool acceptTerms + ) + { + if (acceptTerms is false) + { + return Output.PolicyViolation(["Autorize a coleta de dados para prosseguir."]); + } + + if (birthdate.AddYears(18) > DateTime.Today) + { + return Output.PolicyViolation(["É necessário ser maior de idade para abrir a conta."]); + } + + if (phoneNumber.Length is not 11) + { + return Output.InvalidInput(["O número de telefone deve conter DDD."]); + } + + if (document.Length is not 11 and not 14) + { + return Output.InvalidInput(["O documento deve conter apenas 11 caracteres para PF e 14 para PJ."]); + } + + var emailValidation = Shared.Contracts.Account.ValueObjects.Email.Create(email); + + if (emailValidation.IsFailure) + { + return Output.InvalidInput(emailValidation.Messages); + } + + // TODO: criar value object para phoneNumber e document + + return Output.Created(new Investor( + name, + phoneNumber, + document, + email, + birthdate)); + } +} diff --git a/src/Account/Infra/AccountContext.cs b/src/Account/Infra/AccountContext.cs new file mode 100644 index 0000000..3cc07e4 --- /dev/null +++ b/src/Account/Infra/AccountContext.cs @@ -0,0 +1,25 @@ +using System; +using Kairos.Account.Domain; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Identity.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; + +namespace Kairos.Account.Infra; + +internal sealed class AccountContext : IdentityDbContext +{ + public AccountContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Investors { get; set; } = null!; + + // protected override void OnModelCreating(ModelBuilder builder) + // { + // base.OnModelCreating(builder); + + // additional model configuration if needed + // e.g. builder.Entity(b => { ... }); + // } +} diff --git a/src/Account/Kairos.Account.csproj b/src/Account/Kairos.Account.csproj index 4ac9bf6..d2ba90f 100644 --- a/src/Account/Kairos.Account.csproj +++ b/src/Account/Kairos.Account.csproj @@ -9,4 +9,8 @@ + + + + \ No newline at end of file diff --git a/src/Gateway/Filters/ResponseFormatter.cs b/src/Gateway/Filters/ResponseFormatter.cs index 264d895..76624ad 100644 --- a/src/Gateway/Filters/ResponseFormatter.cs +++ b/src/Gateway/Filters/ResponseFormatter.cs @@ -28,7 +28,7 @@ internal sealed class ResponseFormatter(ILogger logger) : IEn OutputStatus.Empty => StatusCodes.Status204NoContent, OutputStatus.InvalidInput => StatusCodes.Status400BadRequest, OutputStatus.NotFound => StatusCodes.Status404NotFound, - OutputStatus.BusinessLogicViolation => StatusCodes.Status422UnprocessableEntity, + OutputStatus.PolicyViolation => StatusCodes.Status422UnprocessableEntity, _ => StatusCodes.Status500InternalServerError, }; diff --git a/src/Gateway/Modules/AccountModule.cs b/src/Gateway/Modules/AccountModule.cs index d1fa2d0..f7548cb 100644 --- a/src/Gateway/Modules/AccountModule.cs +++ b/src/Gateway/Modules/AccountModule.cs @@ -1,5 +1,5 @@ using Carter; -using Kairos.Shared.Contracts; +using Kairos.Gateway.Filters; using Kairos.Shared.Contracts.Account; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -19,10 +19,13 @@ public AccountModule(IMediator mediator) : base("/api/v1/account") public override void AddRoutes(IEndpointRouteBuilder app) { - app - .MapPost( - "/open", - ([FromBody] OpenAccount command) => _mediator.Send(command)) - .WithDescription("Open an investment account"); + app.MapPost("/open", + ([FromBody] OpenAccountCommand command) => + _mediator.Send(command)) + .WithSummary("Open an investment account") + .Produces>(StatusCodes.Status201Created) + .Produces>(StatusCodes.Status422UnprocessableEntity) + .Produces>(StatusCodes.Status400BadRequest) + .Produces>(StatusCodes.Status500InternalServerError); } } \ No newline at end of file diff --git a/src/Shared/Abstractions/Domain/ValueObject.cs b/src/Shared/Abstractions/Domain/ValueObject.cs new file mode 100644 index 0000000..2b9e639 --- /dev/null +++ b/src/Shared/Abstractions/Domain/ValueObject.cs @@ -0,0 +1,52 @@ +namespace Kairos.Shared.Abstractions.Domain; + +// referência: https://github.com/vkhorikov/CSharpFunctionalExtensions/blob/master/CSharpFunctionalExtensions/ValueObject/ValueObject.cs + +/// +/// Marker interface for Value Objects; It comes in handy for simple +/// values that don't need to implement the complex +/// +public interface IValueObject; + +/// +/// Abstracts complex value objects (more than 1 prop) comparisons +/// +public abstract class ValueObject : IEquatable, IValueObject +{ + int? _cachedHashCode; + + public abstract IEnumerable GetEqualityComponents(); + + public bool Equals(ValueObject? other) => Equals((object?)other); + + public override bool Equals(object? obj) + { + if (obj is null || obj.GetType() != GetType()) return false; + + var otherValueObject = (ValueObject)obj; + + return GetEqualityComponents() + .SequenceEqual(otherValueObject.GetEqualityComponents()); + } + + public static bool operator ==(ValueObject left, ValueObject right) + { + if (left is null && right is null) return true; + + return Equals(left, right); + } + + public static bool operator !=(ValueObject left, ValueObject right) => + !(left == right); + + public override int GetHashCode() + { + if (!_cachedHashCode.HasValue) + _cachedHashCode = GetEqualityComponents() + .Aggregate(1, (total, value) => + unchecked(total * 23 + (value?.GetHashCode() ?? 0)) + ); + + return _cachedHashCode.Value; + } +} diff --git a/src/Shared/Contracts/Account/AccountOpened.cs b/src/Shared/Contracts/Account/AccountOpened.cs index 46be0f6..4925103 100644 --- a/src/Shared/Contracts/Account/AccountOpened.cs +++ b/src/Shared/Contracts/Account/AccountOpened.cs @@ -1,9 +1,11 @@ namespace Kairos.Shared.Contracts.Account; public sealed record AccountOpened( - int Id, - string FirstName, - string LastName, + string Id, + string Name, + string PhoneNumber, string Document, string Email, - DateTime Birthdate); \ No newline at end of file + DateTime Birthdate, + string ConfirmationTokenUrlEncoded, + Guid CorrelationId); \ No newline at end of file diff --git a/src/Shared/Contracts/Account/OpenAccount.cs b/src/Shared/Contracts/Account/OpenAccountCommand.cs similarity index 68% rename from src/Shared/Contracts/Account/OpenAccount.cs rename to src/Shared/Contracts/Account/OpenAccountCommand.cs index a554acf..deb1c83 100644 --- a/src/Shared/Contracts/Account/OpenAccount.cs +++ b/src/Shared/Contracts/Account/OpenAccountCommand.cs @@ -2,9 +2,9 @@ namespace Kairos.Shared.Contracts.Account; -public sealed record OpenAccount( - string FirstName, - string LastName, +public sealed record OpenAccountCommand( + string Name, + string PhoneNumber, string Document, string Email, DateTime Birthdate, diff --git a/src/Shared/Contracts/Account/ValueObjects/Email.cs b/src/Shared/Contracts/Account/ValueObjects/Email.cs new file mode 100644 index 0000000..177e4d7 --- /dev/null +++ b/src/Shared/Contracts/Account/ValueObjects/Email.cs @@ -0,0 +1,37 @@ +using System.Text.RegularExpressions; +using Kairos.Shared.Abstractions.Domain; + +namespace Kairos.Shared.Contracts.Account.ValueObjects; + +public sealed record Email : IValueObject +{ + const string Pattern = @"^.+@.+\..+$"; + const byte MaxLength = 100; + const byte MinLength = 4; + + public string Value { get; init; } + + public static implicit operator Email?(string value) => + Create(value)?.Value; + + Email(string value) => Value = value; + + public static Output Create(string value) + { + string error = value switch + { + var v when string.IsNullOrEmpty(v) => "Email não preenchido", + var v when v.Length < MinLength => $"Email deve ter ao menos {MinLength} caracteres", + var v when v.Length > MaxLength => $"Email não pode ultrapassar {MaxLength} caracteres", + var v when !Regex.IsMatch(v, Pattern) => "Email deve seguir o padrão *@*.*", + _ => string.Empty + }; + + if (!string.IsNullOrEmpty(error)) + { + return Output.InvalidInput([error]); + } + + return Output.Created(new Email(value)); + } +} diff --git a/src/Shared/Contracts/Output.cs b/src/Shared/Contracts/Output.cs index c9e3d88..210efeb 100644 --- a/src/Shared/Contracts/Output.cs +++ b/src/Shared/Contracts/Output.cs @@ -41,8 +41,8 @@ public static Output Created(IEnumerable? messages = null) => public static Output UnexpectedError(IEnumerable messages) => new(OutputStatus.UnexpectedError, messages); - public static Output BusinessLogicViolation(IEnumerable messages) => - new(OutputStatus.BusinessLogicViolation, messages); + public static Output PolicyViolation(IEnumerable messages) => + new(OutputStatus.PolicyViolation, messages); public static Output InvalidInput(IEnumerable messages) => new(OutputStatus.InvalidInput, messages); @@ -86,8 +86,8 @@ public static Output InvalidInput(IEnumerable messages, TValue? public static Output NotFound(IEnumerable messages, TValue? value = default) => new(value, OutputStatus.NotFound, messages); - public static Output BusinessLogicViolation(IEnumerable messages, TValue? value = default) => - new(value, OutputStatus.BusinessLogicViolation, messages); + public static Output PolicyViolation(IEnumerable messages, TValue? value = default) => + new(value, OutputStatus.PolicyViolation, messages); public static Output UnexpectedError(IEnumerable messages, TValue? value = default) => new(value, OutputStatus.UnexpectedError, messages); diff --git a/src/Shared/Contracts/OutputStatus.cs b/src/Shared/Contracts/OutputStatus.cs index 59ae8b1..249a93f 100644 --- a/src/Shared/Contracts/OutputStatus.cs +++ b/src/Shared/Contracts/OutputStatus.cs @@ -33,7 +33,7 @@ public enum OutputStatus /// /// Business logic violation /// - BusinessLogicViolation, + PolicyViolation, /// /// Unexpected internal error diff --git a/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs b/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs index 47996fb..294da61 100644 --- a/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs +++ b/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs @@ -22,7 +22,7 @@ public async Task OpenAccount_HappyPath() // Arrange var ct = TestContext.Current.CancellationToken; - OpenAccount command = new( + OpenAccountCommand command = new( "Foo", "Bar", "51625637263", From a5756234f142f5fcb57a5dc62bc66c347ef1d38a Mon Sep 17 00:00:00 2001 From: sagustavo Date: Tue, 2 Dec 2025 02:26:33 -0300 Subject: [PATCH 27/34] DB migrations --- .config/dotnet-tools.json | 12 + .gitignore | 3 +- Directory.Packages.props | 4 + .../Business/UseCases/OpenAccountUseCase.cs | 10 +- src/Account/DependencyInjection.cs | 3 +- src/Account/Domain/Investor.cs | 1 + src/Account/Infra/AccountContext.cs | 18 +- ...201151524_CreateIdentityTables.Designer.cs | 295 ++++++++++++++++++ .../20251201151524_CreateIdentityTables.cs | 225 +++++++++++++ .../Migrations/AccountContextModelSnapshot.cs | 293 +++++++++++++++++ src/Shared/Kairos.Shared.csproj | 4 + 11 files changed, 851 insertions(+), 17 deletions(-) create mode 100644 .config/dotnet-tools.json create mode 100644 src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.Designer.cs create mode 100644 src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.cs create mode 100644 src/Account/Infra/Migrations/AccountContextModelSnapshot.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..bb90354 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "8.0.22", + "commands": [ + "dotnet-ef" + ] + } + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index c4937db..f301b91 100644 --- a/.gitignore +++ b/.gitignore @@ -401,4 +401,5 @@ FodyWeavers.xsd *.sln.iml .idea -.todo \ No newline at end of file +.todo +.env* \ No newline at end of file diff --git a/Directory.Packages.props b/Directory.Packages.props index ac60234..65d7c80 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -22,6 +22,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + diff --git a/src/Account/Business/UseCases/OpenAccountUseCase.cs b/src/Account/Business/UseCases/OpenAccountUseCase.cs index fba1c60..2e6e323 100644 --- a/src/Account/Business/UseCases/OpenAccountUseCase.cs +++ b/src/Account/Business/UseCases/OpenAccountUseCase.cs @@ -1,4 +1,3 @@ -using System.Data.Common; using Kairos.Account.Domain; using Kairos.Account.Infra; using Kairos.Shared.Contracts; @@ -34,7 +33,7 @@ public async Task Handle( { logger.LogInformation("Starting account opening process"); - var alreadyExists = await db.Investors + Investor? existingAccount = await db.Investors .FirstOrDefaultAsync( i => i.Email == req.Email || @@ -42,13 +41,10 @@ public async Task Handle( i.Document == req.Document, cancellationToken); - // TODO: também validar se existe conta com o mesmo phoneNumber or document - Investor? existingAccount = await identity.FindByEmailAsync(req.Email); - if (existingAccount is not null) { - logger.LogWarning("Email already in use."); - return Output.PolicyViolation(["O e-mail fornecido já está em uso."]); + logger.LogWarning("Account identifier(s) already taken."); + return Output.PolicyViolation(["O e-mail, telefone e/ou documento já está(ão) em uso."]); } var openAccountResult = Investor.OpenAccount( diff --git a/src/Account/DependencyInjection.cs b/src/Account/DependencyInjection.cs index c434b2f..466fc98 100644 --- a/src/Account/DependencyInjection.cs +++ b/src/Account/DependencyInjection.cs @@ -3,7 +3,6 @@ using Kairos.Account.Infra; using Kairos.Account.Infra.Consumers; using Kairos.Shared.Contracts.Account; -using Kairos.Shared.Infra; using MassTransit; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; @@ -73,7 +72,7 @@ static IServiceCollection AddIdentity( o.Tokens.EmailConfirmationTokenProvider = "Default"; o.User.RequireUniqueEmail = true; }) - .AddEntityFrameworkStores() + .AddEntityFrameworkStores() .AddDefaultTokenProviders(); return services; diff --git a/src/Account/Domain/Investor.cs b/src/Account/Domain/Investor.cs index bdb2b63..862ae36 100644 --- a/src/Account/Domain/Investor.cs +++ b/src/Account/Domain/Investor.cs @@ -25,6 +25,7 @@ DateTime birthdate { Name = name; PhoneNumber = phoneNumber; + UserName = document; Document = document; Email = email; Birthdate = birthdate; diff --git a/src/Account/Infra/AccountContext.cs b/src/Account/Infra/AccountContext.cs index 3cc07e4..5ba684b 100644 --- a/src/Account/Infra/AccountContext.cs +++ b/src/Account/Infra/AccountContext.cs @@ -1,4 +1,3 @@ -using System; using Kairos.Account.Domain; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; @@ -15,11 +14,16 @@ public AccountContext(DbContextOptions options) public DbSet Investors { get; set; } = null!; - // protected override void OnModelCreating(ModelBuilder builder) - // { - // base.OnModelCreating(builder); + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); - // additional model configuration if needed - // e.g. builder.Entity(b => { ... }); - // } + builder.Entity(b => b.ToTable("Account")); + builder.Entity(b => b.ToTable("Role")); + builder.Entity>(b => b.ToTable("AccountRole")); + builder.Entity>(b => b.ToTable("AccountClaim")); + builder.Entity>(b => b.ToTable("AccountLogin")); + builder.Entity>(b => b.ToTable("RoleClaim")); + builder.Entity>(b => b.ToTable("AccountToken")); + } } diff --git a/src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.Designer.cs b/src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.Designer.cs new file mode 100644 index 0000000..e927263 --- /dev/null +++ b/src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.Designer.cs @@ -0,0 +1,295 @@ +// +using System; +using Kairos.Account.Infra; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Kairos.Account.Infra.Migrations; + +[DbContext(typeof(AccountContext))] +[Migration("20251201151524_CreateIdentityTables")] +partial class CreateIdentityTables +{ + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.22") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Kairos.Account.Domain.Investor", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Birthdate") + .HasColumnType("datetime2"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Document") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Account", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Role", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaim", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccountClaim", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AccountLogin", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRole", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AccountToken", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } +} \ No newline at end of file diff --git a/src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.cs b/src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.cs new file mode 100644 index 0000000..55b3076 --- /dev/null +++ b/src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.cs @@ -0,0 +1,225 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Kairos.Account.Infra.Migrations; + +/// +public partial class CreateIdentityTables : Migration +{ + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Account", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(max)", nullable: false), + Document = table.Column(type: "nvarchar(max)", nullable: false), + Birthdate = table.Column(type: "datetime2", nullable: false), + Gender = table.Column(type: "int", nullable: false), + Type = table.Column(type: "int", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Account", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Role", + columns: table => new + { + Id = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Role", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AccountClaim", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountClaim", x => x.Id); + table.ForeignKey( + name: "FK_AccountClaim_Account_UserId", + column: x => x.UserId, + principalTable: "Account", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AccountLogin", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountLogin", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AccountLogin_Account_UserId", + column: x => x.UserId, + principalTable: "Account", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AccountToken", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountToken", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AccountToken_Account_UserId", + column: x => x.UserId, + principalTable: "Account", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AccountRole", + columns: table => new + { + UserId = table.Column(type: "nvarchar(450)", nullable: false), + RoleId = table.Column(type: "nvarchar(450)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountRole", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AccountRole_Account_UserId", + column: x => x.UserId, + principalTable: "Account", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AccountRole_Role_RoleId", + column: x => x.RoleId, + principalTable: "Role", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RoleClaim", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "nvarchar(450)", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RoleClaim", x => x.Id); + table.ForeignKey( + name: "FK_RoleClaim_Role_RoleId", + column: x => x.RoleId, + principalTable: "Role", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "Account", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "Account", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AccountClaim_UserId", + table: "AccountClaim", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AccountLogin_UserId", + table: "AccountLogin", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AccountRole_RoleId", + table: "AccountRole", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "Role", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_RoleClaim_RoleId", + table: "RoleClaim", + column: "RoleId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AccountClaim"); + + migrationBuilder.DropTable( + name: "AccountLogin"); + + migrationBuilder.DropTable( + name: "AccountRole"); + + migrationBuilder.DropTable( + name: "AccountToken"); + + migrationBuilder.DropTable( + name: "RoleClaim"); + + migrationBuilder.DropTable( + name: "Account"); + + migrationBuilder.DropTable( + name: "Role"); + } +} diff --git a/src/Account/Infra/Migrations/AccountContextModelSnapshot.cs b/src/Account/Infra/Migrations/AccountContextModelSnapshot.cs new file mode 100644 index 0000000..f568c65 --- /dev/null +++ b/src/Account/Infra/Migrations/AccountContextModelSnapshot.cs @@ -0,0 +1,293 @@ +// +using System; +using Kairos.Account.Infra; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Kairos.Account.Infra.Migrations +{ + [DbContext(typeof(AccountContext))] + partial class AccountContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.22") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Kairos.Account.Domain.Investor", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Birthdate") + .HasColumnType("datetime2"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Document") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Account", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("nvarchar(450)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Role", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaim", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccountClaim", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AccountLogin", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("RoleId") + .HasColumnType("nvarchar(450)"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRole", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("nvarchar(450)"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AccountToken", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Shared/Kairos.Shared.csproj b/src/Shared/Kairos.Shared.csproj index 8cfef2f..1ed2d2d 100644 --- a/src/Shared/Kairos.Shared.csproj +++ b/src/Shared/Kairos.Shared.csproj @@ -11,6 +11,10 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + From 89636c3d3a73ebb5be686bd2efc0ed798677e4f9 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Fri, 5 Dec 2025 01:48:09 -0300 Subject: [PATCH 28/34] E-mail confirmation --- README.md | 12 + .../Business/UseCases/ConfirmEmailUseCase.cs | 71 +++ .../Business/UseCases/OpenAccountUseCase.cs | 8 +- src/Account/DependencyInjection.cs | 2 +- .../Domain/Abstraction/KairosAccount.cs | 2 +- src/Account/Infra/AccountContext.cs | 14 +- ...201151524_CreateIdentityTables.Designer.cs | 295 --------- .../20251201151524_CreateIdentityTables.cs | 225 ------- ...205040738_CreateIdentityTables.Designer.cs | 299 +++++++++ .../20251205040738_CreateIdentityTables.cs | 231 +++++++ .../Migrations/AccountContextModelSnapshot.cs | 589 +++++++++--------- src/Gateway/Modules/AccountModule.cs | 19 +- .../Contracts/Account/ConfirmEmailCommand.cs | 9 + .../{ => OpenAccount}/AccountOpened.cs | 4 +- .../{ => OpenAccount}/OpenAccountCommand.cs | 0 src/Shared/Contracts/Output.cs | 4 + 16 files changed, 952 insertions(+), 832 deletions(-) create mode 100644 src/Account/Business/UseCases/ConfirmEmailUseCase.cs delete mode 100644 src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.Designer.cs delete mode 100644 src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.cs create mode 100644 src/Account/Infra/Migrations/20251205040738_CreateIdentityTables.Designer.cs create mode 100644 src/Account/Infra/Migrations/20251205040738_CreateIdentityTables.cs create mode 100644 src/Shared/Contracts/Account/ConfirmEmailCommand.cs rename src/Shared/Contracts/Account/{ => OpenAccount}/AccountOpened.cs (76%) rename src/Shared/Contracts/Account/{ => OpenAccount}/OpenAccountCommand.cs (100%) diff --git a/README.md b/README.md index e486c02..f7f30c0 100644 --- a/README.md +++ b/README.md @@ -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 tool run dotnet-ef database update -p src/SpecificModule -s src/Gateway --context SpecificModuleContext ``` \ No newline at end of file diff --git a/src/Account/Business/UseCases/ConfirmEmailUseCase.cs b/src/Account/Business/UseCases/ConfirmEmailUseCase.cs new file mode 100644 index 0000000..6e807f3 --- /dev/null +++ b/src/Account/Business/UseCases/ConfirmEmailUseCase.cs @@ -0,0 +1,71 @@ +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 logger, + UserManager identity +) : IRequestHandler +{ + public async Task Handle(ConfirmEmailCommand input, CancellationToken cancellationToken) + { + var enrichers = new Dictionary + { + ["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 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!"]); + } +} diff --git a/src/Account/Business/UseCases/OpenAccountUseCase.cs b/src/Account/Business/UseCases/OpenAccountUseCase.cs index 2e6e323..7d5a3e2 100644 --- a/src/Account/Business/UseCases/OpenAccountUseCase.cs +++ b/src/Account/Business/UseCases/OpenAccountUseCase.cs @@ -63,6 +63,8 @@ public async Task Handle( Investor investor = openAccountResult.Value!; + // TODO: usar transaction/outbox + var identityResult = await identity.CreateAsync(investor); if (identityResult.Succeeded is false) @@ -76,8 +78,6 @@ public async Task Handle( var token = await identity.GenerateEmailConfirmationTokenAsync(investor); - logger.LogInformation("Account {AccountId} opened!", investor.Id); - await bus.Publish( new AccountOpened( investor.Id, @@ -86,11 +86,13 @@ await bus.Publish( req.Document, req.Email, req.Birthdate, - Uri.EscapeDataString(token), + token, req.CorrelationId), ctx => ctx.CorrelationId = req.CorrelationId, cancellationToken); + logger.LogInformation("Account {AccountId} opened!", investor.Id); + return Output.Created([ $"Conta de investimento {investor.Id} aberta!", "Confirme a abertura no e-mail que será enviado em instantes."]); diff --git a/src/Account/DependencyInjection.cs b/src/Account/DependencyInjection.cs index 466fc98..6972e6a 100644 --- a/src/Account/DependencyInjection.cs +++ b/src/Account/DependencyInjection.cs @@ -59,7 +59,7 @@ static IServiceCollection AddIdentity( { services .AddDbContext(o => o.UseSqlServer(config["Database:Broker:ConnectionString"]!)) - .AddIdentity(o => + .AddIdentity>(o => { o.Password.RequireDigit = true; o.Password.RequiredLength = 6; diff --git a/src/Account/Domain/Abstraction/KairosAccount.cs b/src/Account/Domain/Abstraction/KairosAccount.cs index d0f8a69..1d23951 100644 --- a/src/Account/Domain/Abstraction/KairosAccount.cs +++ b/src/Account/Domain/Abstraction/KairosAccount.cs @@ -2,4 +2,4 @@ namespace Kairos.Account.Domain.Abstraction; -public abstract class KairosAccount : IdentityUser; +public abstract class KairosAccount : IdentityUser; diff --git a/src/Account/Infra/AccountContext.cs b/src/Account/Infra/AccountContext.cs index 5ba684b..d0b67a7 100644 --- a/src/Account/Infra/AccountContext.cs +++ b/src/Account/Infra/AccountContext.cs @@ -5,7 +5,7 @@ namespace Kairos.Account.Infra; -internal sealed class AccountContext : IdentityDbContext +internal sealed class AccountContext : IdentityDbContext, long> { public AccountContext(DbContextOptions options) : base(options) @@ -19,11 +19,11 @@ protected override void OnModelCreating(ModelBuilder builder) base.OnModelCreating(builder); builder.Entity(b => b.ToTable("Account")); - builder.Entity(b => b.ToTable("Role")); - builder.Entity>(b => b.ToTable("AccountRole")); - builder.Entity>(b => b.ToTable("AccountClaim")); - builder.Entity>(b => b.ToTable("AccountLogin")); - builder.Entity>(b => b.ToTable("RoleClaim")); - builder.Entity>(b => b.ToTable("AccountToken")); + builder.Entity>(b => b.ToTable("Role")); + builder.Entity>(b => b.ToTable("AccountRole")); + builder.Entity>(b => b.ToTable("AccountClaim")); + builder.Entity>(b => b.ToTable("AccountLogin")); + builder.Entity>(b => b.ToTable("RoleClaim")); + builder.Entity>(b => b.ToTable("AccountToken")); } } diff --git a/src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.Designer.cs b/src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.Designer.cs deleted file mode 100644 index e927263..0000000 --- a/src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.Designer.cs +++ /dev/null @@ -1,295 +0,0 @@ -// -using System; -using Kairos.Account.Infra; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace Kairos.Account.Infra.Migrations; - -[DbContext(typeof(AccountContext))] -[Migration("20251201151524_CreateIdentityTables")] -partial class CreateIdentityTables -{ - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.22") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("Kairos.Account.Domain.Investor", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Birthdate") - .HasColumnType("datetime2"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("Document") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("Gender") - .HasColumnType("int"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("Type") - .HasColumnType("int"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("Account", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("Role", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("RoleClaim", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AccountClaim", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AccountLogin", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AccountRole", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AccountToken", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("Kairos.Account.Domain.Investor", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("Kairos.Account.Domain.Investor", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Kairos.Account.Domain.Investor", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("Kairos.Account.Domain.Investor", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } -} \ No newline at end of file diff --git a/src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.cs b/src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.cs deleted file mode 100644 index 55b3076..0000000 --- a/src/Account/Infra/Migrations/20251201151524_CreateIdentityTables.cs +++ /dev/null @@ -1,225 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -namespace Kairos.Account.Infra.Migrations; - -/// -public partial class CreateIdentityTables : Migration -{ - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateTable( - name: "Account", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(max)", nullable: false), - Document = table.Column(type: "nvarchar(max)", nullable: false), - Birthdate = table.Column(type: "datetime2", nullable: false), - Gender = table.Column(type: "int", nullable: false), - Type = table.Column(type: "int", nullable: false), - UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - EmailConfirmed = table.Column(type: "bit", nullable: false), - PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), - SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), - ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), - PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), - TwoFactorEnabled = table.Column(type: "bit", nullable: false), - LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), - LockoutEnabled = table.Column(type: "bit", nullable: false), - AccessFailedCount = table.Column(type: "int", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_Account", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "Role", - columns: table => new - { - Id = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), - ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_Role", x => x.Id); - }); - - migrationBuilder.CreateTable( - name: "AccountClaim", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - UserId = table.Column(type: "nvarchar(450)", nullable: false), - ClaimType = table.Column(type: "nvarchar(max)", nullable: true), - ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AccountClaim", x => x.Id); - table.ForeignKey( - name: "FK_AccountClaim_Account_UserId", - column: x => x.UserId, - principalTable: "Account", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AccountLogin", - columns: table => new - { - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), - ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), - UserId = table.Column(type: "nvarchar(450)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AccountLogin", x => new { x.LoginProvider, x.ProviderKey }); - table.ForeignKey( - name: "FK_AccountLogin_Account_UserId", - column: x => x.UserId, - principalTable: "Account", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AccountToken", - columns: table => new - { - UserId = table.Column(type: "nvarchar(450)", nullable: false), - LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), - Name = table.Column(type: "nvarchar(450)", nullable: false), - Value = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_AccountToken", x => new { x.UserId, x.LoginProvider, x.Name }); - table.ForeignKey( - name: "FK_AccountToken_Account_UserId", - column: x => x.UserId, - principalTable: "Account", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "AccountRole", - columns: table => new - { - UserId = table.Column(type: "nvarchar(450)", nullable: false), - RoleId = table.Column(type: "nvarchar(450)", nullable: false) - }, - constraints: table => - { - table.PrimaryKey("PK_AccountRole", x => new { x.UserId, x.RoleId }); - table.ForeignKey( - name: "FK_AccountRole_Account_UserId", - column: x => x.UserId, - principalTable: "Account", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - table.ForeignKey( - name: "FK_AccountRole_Role_RoleId", - column: x => x.RoleId, - principalTable: "Role", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateTable( - name: "RoleClaim", - columns: table => new - { - Id = table.Column(type: "int", nullable: false) - .Annotation("SqlServer:Identity", "1, 1"), - RoleId = table.Column(type: "nvarchar(450)", nullable: false), - ClaimType = table.Column(type: "nvarchar(max)", nullable: true), - ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) - }, - constraints: table => - { - table.PrimaryKey("PK_RoleClaim", x => x.Id); - table.ForeignKey( - name: "FK_RoleClaim_Role_RoleId", - column: x => x.RoleId, - principalTable: "Role", - principalColumn: "Id", - onDelete: ReferentialAction.Cascade); - }); - - migrationBuilder.CreateIndex( - name: "EmailIndex", - table: "Account", - column: "NormalizedEmail"); - - migrationBuilder.CreateIndex( - name: "UserNameIndex", - table: "Account", - column: "NormalizedUserName", - unique: true, - filter: "[NormalizedUserName] IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_AccountClaim_UserId", - table: "AccountClaim", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AccountLogin_UserId", - table: "AccountLogin", - column: "UserId"); - - migrationBuilder.CreateIndex( - name: "IX_AccountRole_RoleId", - table: "AccountRole", - column: "RoleId"); - - migrationBuilder.CreateIndex( - name: "RoleNameIndex", - table: "Role", - column: "NormalizedName", - unique: true, - filter: "[NormalizedName] IS NOT NULL"); - - migrationBuilder.CreateIndex( - name: "IX_RoleClaim_RoleId", - table: "RoleClaim", - column: "RoleId"); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "AccountClaim"); - - migrationBuilder.DropTable( - name: "AccountLogin"); - - migrationBuilder.DropTable( - name: "AccountRole"); - - migrationBuilder.DropTable( - name: "AccountToken"); - - migrationBuilder.DropTable( - name: "RoleClaim"); - - migrationBuilder.DropTable( - name: "Account"); - - migrationBuilder.DropTable( - name: "Role"); - } -} diff --git a/src/Account/Infra/Migrations/20251205040738_CreateIdentityTables.Designer.cs b/src/Account/Infra/Migrations/20251205040738_CreateIdentityTables.Designer.cs new file mode 100644 index 0000000..e9a7402 --- /dev/null +++ b/src/Account/Infra/Migrations/20251205040738_CreateIdentityTables.Designer.cs @@ -0,0 +1,299 @@ +// +using System; +using Kairos.Account.Infra; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Kairos.Account.Infra.Migrations +{ + [DbContext(typeof(AccountContext))] + [Migration("20251205040738_CreateIdentityTables")] + partial class CreateIdentityTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.22") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Kairos.Account.Domain.Investor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Birthdate") + .HasColumnType("datetime2"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Document") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Account", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Role", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaim", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccountClaim", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AccountLogin", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRole", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AccountToken", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Account/Infra/Migrations/20251205040738_CreateIdentityTables.cs b/src/Account/Infra/Migrations/20251205040738_CreateIdentityTables.cs new file mode 100644 index 0000000..673d0c5 --- /dev/null +++ b/src/Account/Infra/Migrations/20251205040738_CreateIdentityTables.cs @@ -0,0 +1,231 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kairos.Account.Infra.Migrations +{ + /// + public partial class CreateIdentityTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Account", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(max)", nullable: false), + Document = table.Column(type: "nvarchar(max)", nullable: false), + Birthdate = table.Column(type: "datetime2", nullable: false), + Gender = table.Column(type: "int", nullable: false), + Type = table.Column(type: "int", nullable: false), + UserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "bit", nullable: false), + PasswordHash = table.Column(type: "nvarchar(max)", nullable: true), + SecurityStamp = table.Column(type: "nvarchar(max)", nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumber = table.Column(type: "nvarchar(max)", nullable: true), + PhoneNumberConfirmed = table.Column(type: "bit", nullable: false), + TwoFactorEnabled = table.Column(type: "bit", nullable: false), + LockoutEnd = table.Column(type: "datetimeoffset", nullable: true), + LockoutEnabled = table.Column(type: "bit", nullable: false), + AccessFailedCount = table.Column(type: "int", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Account", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Role", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Role", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AccountClaim", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + UserId = table.Column(type: "bigint", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountClaim", x => x.Id); + table.ForeignKey( + name: "FK_AccountClaim_Account_UserId", + column: x => x.UserId, + principalTable: "Account", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AccountLogin", + columns: table => new + { + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + ProviderKey = table.Column(type: "nvarchar(450)", nullable: false), + ProviderDisplayName = table.Column(type: "nvarchar(max)", nullable: true), + UserId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountLogin", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AccountLogin_Account_UserId", + column: x => x.UserId, + principalTable: "Account", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AccountToken", + columns: table => new + { + UserId = table.Column(type: "bigint", nullable: false), + LoginProvider = table.Column(type: "nvarchar(450)", nullable: false), + Name = table.Column(type: "nvarchar(450)", nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountToken", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AccountToken_Account_UserId", + column: x => x.UserId, + principalTable: "Account", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AccountRole", + columns: table => new + { + UserId = table.Column(type: "bigint", nullable: false), + RoleId = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountRole", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AccountRole_Account_UserId", + column: x => x.UserId, + principalTable: "Account", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AccountRole_Role_RoleId", + column: x => x.RoleId, + principalTable: "Role", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "RoleClaim", + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + RoleId = table.Column(type: "bigint", nullable: false), + ClaimType = table.Column(type: "nvarchar(max)", nullable: true), + ClaimValue = table.Column(type: "nvarchar(max)", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_RoleClaim", x => x.Id); + table.ForeignKey( + name: "FK_RoleClaim_Role_RoleId", + column: x => x.RoleId, + principalTable: "Role", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "Account", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "Account", + column: "NormalizedUserName", + unique: true, + filter: "[NormalizedUserName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_AccountClaim_UserId", + table: "AccountClaim", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AccountLogin_UserId", + table: "AccountLogin", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AccountRole_RoleId", + table: "AccountRole", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "Role", + column: "NormalizedName", + unique: true, + filter: "[NormalizedName] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_RoleClaim_RoleId", + table: "RoleClaim", + column: "RoleId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AccountClaim"); + + migrationBuilder.DropTable( + name: "AccountLogin"); + + migrationBuilder.DropTable( + name: "AccountRole"); + + migrationBuilder.DropTable( + name: "AccountToken"); + + migrationBuilder.DropTable( + name: "RoleClaim"); + + migrationBuilder.DropTable( + name: "Account"); + + migrationBuilder.DropTable( + name: "Role"); + } + } +} diff --git a/src/Account/Infra/Migrations/AccountContextModelSnapshot.cs b/src/Account/Infra/Migrations/AccountContextModelSnapshot.cs index f568c65..d5b588e 100644 --- a/src/Account/Infra/Migrations/AccountContextModelSnapshot.cs +++ b/src/Account/Infra/Migrations/AccountContextModelSnapshot.cs @@ -1,293 +1,296 @@ -// -using System; -using Kairos.Account.Infra; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Metadata; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; - -#nullable disable - -namespace Kairos.Account.Infra.Migrations -{ - [DbContext(typeof(AccountContext))] - partial class AccountContextModelSnapshot : ModelSnapshot - { - protected override void BuildModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "8.0.22") - .HasAnnotation("Relational:MaxIdentifierLength", 128); - - SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - - modelBuilder.Entity("Kairos.Account.Domain.Investor", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("AccessFailedCount") - .HasColumnType("int"); - - b.Property("Birthdate") - .HasColumnType("datetime2"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("Document") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("Email") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("EmailConfirmed") - .HasColumnType("bit"); - - b.Property("Gender") - .HasColumnType("int"); - - b.Property("LockoutEnabled") - .HasColumnType("bit"); - - b.Property("LockoutEnd") - .HasColumnType("datetimeoffset"); - - b.Property("Name") - .IsRequired() - .HasColumnType("nvarchar(max)"); - - b.Property("NormalizedEmail") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedUserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("PasswordHash") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumber") - .HasColumnType("nvarchar(max)"); - - b.Property("PhoneNumberConfirmed") - .HasColumnType("bit"); - - b.Property("SecurityStamp") - .HasColumnType("nvarchar(max)"); - - b.Property("TwoFactorEnabled") - .HasColumnType("bit"); - - b.Property("Type") - .HasColumnType("int"); - - b.Property("UserName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedEmail") - .HasDatabaseName("EmailIndex"); - - b.HasIndex("NormalizedUserName") - .IsUnique() - .HasDatabaseName("UserNameIndex") - .HasFilter("[NormalizedUserName] IS NOT NULL"); - - b.ToTable("Account", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => - { - b.Property("Id") - .HasColumnType("nvarchar(450)"); - - b.Property("ConcurrencyStamp") - .IsConcurrencyToken() - .HasColumnType("nvarchar(max)"); - - b.Property("Name") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.Property("NormalizedName") - .HasMaxLength(256) - .HasColumnType("nvarchar(256)"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedName") - .IsUnique() - .HasDatabaseName("RoleNameIndex") - .HasFilter("[NormalizedName] IS NOT NULL"); - - b.ToTable("Role", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("RoleId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("RoleId"); - - b.ToTable("RoleClaim", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("int"); - - SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); - - b.Property("ClaimType") - .HasColumnType("nvarchar(max)"); - - b.Property("ClaimValue") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("Id"); - - b.HasIndex("UserId"); - - b.ToTable("AccountClaim", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderKey") - .HasColumnType("nvarchar(450)"); - - b.Property("ProviderDisplayName") - .HasColumnType("nvarchar(max)"); - - b.Property("UserId") - .IsRequired() - .HasColumnType("nvarchar(450)"); - - b.HasKey("LoginProvider", "ProviderKey"); - - b.HasIndex("UserId"); - - b.ToTable("AccountLogin", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("RoleId") - .HasColumnType("nvarchar(450)"); - - b.HasKey("UserId", "RoleId"); - - b.HasIndex("RoleId"); - - b.ToTable("AccountRole", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.Property("UserId") - .HasColumnType("nvarchar(450)"); - - b.Property("LoginProvider") - .HasColumnType("nvarchar(450)"); - - b.Property("Name") - .HasColumnType("nvarchar(450)"); - - b.Property("Value") - .HasColumnType("nvarchar(max)"); - - b.HasKey("UserId", "LoginProvider", "Name"); - - b.ToTable("AccountToken", (string)null); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => - { - b.HasOne("Kairos.Account.Domain.Investor", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => - { - b.HasOne("Kairos.Account.Domain.Investor", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => - { - b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) - .WithMany() - .HasForeignKey("RoleId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Kairos.Account.Domain.Investor", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); - - modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => - { - b.HasOne("Kairos.Account.Domain.Investor", null) - .WithMany() - .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - }); -#pragma warning restore 612, 618 - } - } -} +// +using System; +using Kairos.Account.Infra; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Kairos.Account.Infra.Migrations +{ + [DbContext(typeof(AccountContext))] + partial class AccountContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.22") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Kairos.Account.Domain.Investor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Birthdate") + .HasColumnType("datetime2"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Document") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Account", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Role", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaim", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccountClaim", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AccountLogin", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRole", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AccountToken", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Gateway/Modules/AccountModule.cs b/src/Gateway/Modules/AccountModule.cs index f7548cb..2d65af2 100644 --- a/src/Gateway/Modules/AccountModule.cs +++ b/src/Gateway/Modules/AccountModule.cs @@ -1,8 +1,8 @@ using Carter; -using Kairos.Gateway.Filters; using Kairos.Shared.Contracts.Account; using MediatR; using Microsoft.AspNetCore.Mvc; +using Response = Kairos.Gateway.Filters.Response; namespace Kairos.Gateway.Modules; @@ -23,9 +23,18 @@ public override void AddRoutes(IEndpointRouteBuilder app) ([FromBody] OpenAccountCommand command) => _mediator.Send(command)) .WithSummary("Open an investment account") - .Produces>(StatusCodes.Status201Created) - .Produces>(StatusCodes.Status422UnprocessableEntity) - .Produces>(StatusCodes.Status400BadRequest) - .Produces>(StatusCodes.Status500InternalServerError); + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status422UnprocessableEntity) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError); + + app.MapPatch("/confirm-email", + ([FromBody] ConfirmEmailCommand command) => + _mediator.Send(command)) + .WithSummary("Account opening e-mail confirmation") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status422UnprocessableEntity) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError); } } \ No newline at end of file diff --git a/src/Shared/Contracts/Account/ConfirmEmailCommand.cs b/src/Shared/Contracts/Account/ConfirmEmailCommand.cs new file mode 100644 index 0000000..bd4c498 --- /dev/null +++ b/src/Shared/Contracts/Account/ConfirmEmailCommand.cs @@ -0,0 +1,9 @@ +using System; +using Kairos.Shared.Abstractions; + +namespace Kairos.Shared.Contracts.Account; + +public sealed record ConfirmEmailCommand( + long AccountId, + string ConfirmationToken, + Guid CorrelationId) : ICommand; diff --git a/src/Shared/Contracts/Account/AccountOpened.cs b/src/Shared/Contracts/Account/OpenAccount/AccountOpened.cs similarity index 76% rename from src/Shared/Contracts/Account/AccountOpened.cs rename to src/Shared/Contracts/Account/OpenAccount/AccountOpened.cs index 4925103..d5f8c34 100644 --- a/src/Shared/Contracts/Account/AccountOpened.cs +++ b/src/Shared/Contracts/Account/OpenAccount/AccountOpened.cs @@ -1,11 +1,11 @@ namespace Kairos.Shared.Contracts.Account; public sealed record AccountOpened( - string Id, + long Id, string Name, string PhoneNumber, string Document, string Email, DateTime Birthdate, - string ConfirmationTokenUrlEncoded, + string ConfirmationToken, Guid CorrelationId); \ No newline at end of file diff --git a/src/Shared/Contracts/Account/OpenAccountCommand.cs b/src/Shared/Contracts/Account/OpenAccount/OpenAccountCommand.cs similarity index 100% rename from src/Shared/Contracts/Account/OpenAccountCommand.cs rename to src/Shared/Contracts/Account/OpenAccount/OpenAccountCommand.cs diff --git a/src/Shared/Contracts/Output.cs b/src/Shared/Contracts/Output.cs index 210efeb..2061ab6 100644 --- a/src/Shared/Contracts/Output.cs +++ b/src/Shared/Contracts/Output.cs @@ -31,6 +31,10 @@ public Output(Output result) } #region Success + + public static Output Ok(IEnumerable? messages = null) => + new(OutputStatus.Ok, messages ?? []); + public static Output Created(IEnumerable? messages = null) => new(OutputStatus.Created, messages ?? []); From c8a5788b6bfa5bb6f15fa22968ce36a6426c2a9f Mon Sep 17 00:00:00 2001 From: sagustavo Date: Fri, 5 Dec 2025 02:56:57 -0300 Subject: [PATCH 29/34] Outbox pattern --- Directory.Packages.props | 5 +- README.md | 8 +- src/Account/DependencyInjection.cs | 7 +- src/Account/Infra/AccountContext.cs | 6 + ..._CreateMassTransitOutboxTables.Designer.cs | 467 ++++++++++++++++++ ...205055526_CreateMassTransitOutboxTables.cs | 132 +++++ .../Migrations/AccountContextModelSnapshot.cs | 168 +++++++ src/Gateway/DependencyInjection.cs | 6 +- src/Gateway/Modules/AccountModule.cs | 20 +- src/Shared/Kairos.Shared.csproj | 1 + .../UseCases/OpenAccountUseCaseTests.cs | 9 +- 11 files changed, 817 insertions(+), 12 deletions(-) create mode 100644 src/Account/Infra/Migrations/20251205055526_CreateMassTransitOutboxTables.Designer.cs create mode 100644 src/Account/Infra/Migrations/20251205055526_CreateMassTransitOutboxTables.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 65d7c80..5bc9da9 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -17,7 +17,8 @@ - + + @@ -26,7 +27,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - + diff --git a/README.md b/README.md index f7f30c0..3a4fc12 100644 --- a/README.md +++ b/README.md @@ -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/) High Level Architecture @@ -227,5 +227,5 @@ dotnet ef migrations add MigrationName -p src/SpecificModule -s src/Gateway -o I Apply the migrations: ```sh -dotnet tool run dotnet-ef database update -p src/SpecificModule -s src/Gateway --context SpecificModuleContext +dotnet ef database update -p src/SpecificModule -s src/Gateway --context SpecificModuleContext ``` \ No newline at end of file diff --git a/src/Account/DependencyInjection.cs b/src/Account/DependencyInjection.cs index 6972e6a..521549a 100644 --- a/src/Account/DependencyInjection.cs +++ b/src/Account/DependencyInjection.cs @@ -26,9 +26,14 @@ public static IServiceCollection AddAccount( }); } - public static IBusRegistrationConfigurator AddAccountConsumers(this IBusRegistrationConfigurator x) + public static IBusRegistrationConfigurator ConfigureAccountBus(this IBusRegistrationConfigurator x) { x.AddConsumers(Assembly.GetExecutingAssembly()); + x.AddEntityFrameworkOutbox(c => + { + c.UseSqlServer(); + c.UseBusOutbox(); + }); return x; } diff --git a/src/Account/Infra/AccountContext.cs b/src/Account/Infra/AccountContext.cs index d0b67a7..487fa99 100644 --- a/src/Account/Infra/AccountContext.cs +++ b/src/Account/Infra/AccountContext.cs @@ -1,4 +1,5 @@ using Kairos.Account.Domain; +using MassTransit; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; @@ -25,5 +26,10 @@ protected override void OnModelCreating(ModelBuilder builder) builder.Entity>(b => b.ToTable("AccountLogin")); builder.Entity>(b => b.ToTable("RoleClaim")); builder.Entity>(b => b.ToTable("AccountToken")); + + // Outbox pattern + builder.AddInboxStateEntity(); + builder.AddOutboxMessageEntity(); + builder.AddOutboxStateEntity(); } } diff --git a/src/Account/Infra/Migrations/20251205055526_CreateMassTransitOutboxTables.Designer.cs b/src/Account/Infra/Migrations/20251205055526_CreateMassTransitOutboxTables.Designer.cs new file mode 100644 index 0000000..f18e959 --- /dev/null +++ b/src/Account/Infra/Migrations/20251205055526_CreateMassTransitOutboxTables.Designer.cs @@ -0,0 +1,467 @@ +// +using System; +using Kairos.Account.Infra; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Kairos.Account.Infra.Migrations +{ + [DbContext(typeof(AccountContext))] + [Migration("20251205055526_CreateMassTransitOutboxTables")] + partial class CreateMassTransitOutboxTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.22") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Kairos.Account.Domain.Investor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("Birthdate") + .HasColumnType("datetime2"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Document") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("Gender") + .HasColumnType("int"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("Name") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("Type") + .HasColumnType("int"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("Account", (string)null); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("datetime2"); + + b.Property("ConsumerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Delivered") + .HasColumnType("datetime2"); + + b.Property("ExpirationTime") + .HasColumnType("datetime2"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uniqueidentifier"); + + b.Property("MessageId") + .HasColumnType("uniqueidentifier"); + + b.Property("ReceiveCount") + .HasColumnType("int"); + + b.Property("Received") + .HasColumnType("datetime2"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("Id"); + + b.HasAlternateKey("MessageId", "ConsumerId"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EnqueueTime") + .HasColumnType("datetime2"); + + b.Property("ExpirationTime") + .HasColumnType("datetime2"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Headers") + .HasColumnType("nvarchar(max)"); + + b.Property("InboxConsumerId") + .HasColumnType("uniqueidentifier"); + + b.Property("InboxMessageId") + .HasColumnType("uniqueidentifier"); + + b.Property("InitiatorId") + .HasColumnType("uniqueidentifier"); + + b.Property("MessageId") + .HasColumnType("uniqueidentifier"); + + b.Property("OutboxId") + .HasColumnType("uniqueidentifier"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SentTime") + .HasColumnType("datetime2"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique() + .HasFilter("[OutboxId] IS NOT NULL"); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique() + .HasFilter("[InboxMessageId] IS NOT NULL AND [InboxConsumerId] IS NOT NULL"); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("Delivered") + .HasColumnType("datetime2"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uniqueidentifier"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("Role", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("RoleClaim", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccountClaim", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AccountLogin", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("RoleId") + .HasColumnType("bigint"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AccountRole", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AccountToken", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Kairos.Account.Domain.Investor", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Account/Infra/Migrations/20251205055526_CreateMassTransitOutboxTables.cs b/src/Account/Infra/Migrations/20251205055526_CreateMassTransitOutboxTables.cs new file mode 100644 index 0000000..35e83f3 --- /dev/null +++ b/src/Account/Infra/Migrations/20251205055526_CreateMassTransitOutboxTables.cs @@ -0,0 +1,132 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kairos.Account.Infra.Migrations +{ + /// + public partial class CreateMassTransitOutboxTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "InboxState", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + MessageId = table.Column(type: "uniqueidentifier", nullable: false), + ConsumerId = table.Column(type: "uniqueidentifier", nullable: false), + LockId = table.Column(type: "uniqueidentifier", nullable: false), + RowVersion = table.Column(type: "rowversion", rowVersion: true, nullable: true), + Received = table.Column(type: "datetime2", nullable: false), + ReceiveCount = table.Column(type: "int", nullable: false), + ExpirationTime = table.Column(type: "datetime2", nullable: true), + Consumed = table.Column(type: "datetime2", nullable: true), + Delivered = table.Column(type: "datetime2", nullable: true), + LastSequenceNumber = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_InboxState", x => x.Id); + table.UniqueConstraint("AK_InboxState_MessageId_ConsumerId", x => new { x.MessageId, x.ConsumerId }); + }); + + migrationBuilder.CreateTable( + name: "OutboxMessage", + columns: table => new + { + SequenceNumber = table.Column(type: "bigint", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + EnqueueTime = table.Column(type: "datetime2", nullable: true), + SentTime = table.Column(type: "datetime2", nullable: false), + Headers = table.Column(type: "nvarchar(max)", nullable: true), + Properties = table.Column(type: "nvarchar(max)", nullable: true), + InboxMessageId = table.Column(type: "uniqueidentifier", nullable: true), + InboxConsumerId = table.Column(type: "uniqueidentifier", nullable: true), + OutboxId = table.Column(type: "uniqueidentifier", nullable: true), + MessageId = table.Column(type: "uniqueidentifier", nullable: false), + ContentType = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Body = table.Column(type: "nvarchar(max)", nullable: false), + ConversationId = table.Column(type: "uniqueidentifier", nullable: true), + CorrelationId = table.Column(type: "uniqueidentifier", nullable: true), + InitiatorId = table.Column(type: "uniqueidentifier", nullable: true), + RequestId = table.Column(type: "uniqueidentifier", nullable: true), + SourceAddress = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + DestinationAddress = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ResponseAddress = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + FaultAddress = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: true), + ExpirationTime = table.Column(type: "datetime2", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxMessage", x => x.SequenceNumber); + }); + + migrationBuilder.CreateTable( + name: "OutboxState", + columns: table => new + { + OutboxId = table.Column(type: "uniqueidentifier", nullable: false), + LockId = table.Column(type: "uniqueidentifier", nullable: false), + RowVersion = table.Column(type: "rowversion", rowVersion: true, nullable: true), + Created = table.Column(type: "datetime2", nullable: false), + Delivered = table.Column(type: "datetime2", nullable: true), + LastSequenceNumber = table.Column(type: "bigint", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OutboxState", x => x.OutboxId); + }); + + migrationBuilder.CreateIndex( + name: "IX_InboxState_Delivered", + table: "InboxState", + column: "Delivered"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_EnqueueTime", + table: "OutboxMessage", + column: "EnqueueTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_ExpirationTime", + table: "OutboxMessage", + column: "ExpirationTime"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_InboxMessageId_InboxConsumerId_SequenceNumber", + table: "OutboxMessage", + columns: new[] { "InboxMessageId", "InboxConsumerId", "SequenceNumber" }, + unique: true, + filter: "[InboxMessageId] IS NOT NULL AND [InboxConsumerId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxMessage_OutboxId_SequenceNumber", + table: "OutboxMessage", + columns: new[] { "OutboxId", "SequenceNumber" }, + unique: true, + filter: "[OutboxId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_OutboxState_Created", + table: "OutboxState", + column: "Created"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "InboxState"); + + migrationBuilder.DropTable( + name: "OutboxMessage"); + + migrationBuilder.DropTable( + name: "OutboxState"); + } + } +} diff --git a/src/Account/Infra/Migrations/AccountContextModelSnapshot.cs b/src/Account/Infra/Migrations/AccountContextModelSnapshot.cs index d5b588e..3d3fc2e 100644 --- a/src/Account/Infra/Migrations/AccountContextModelSnapshot.cs +++ b/src/Account/Infra/Migrations/AccountContextModelSnapshot.cs @@ -107,6 +107,174 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("Account", (string)null); }); + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("datetime2"); + + b.Property("ConsumerId") + .HasColumnType("uniqueidentifier"); + + b.Property("Delivered") + .HasColumnType("datetime2"); + + b.Property("ExpirationTime") + .HasColumnType("datetime2"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uniqueidentifier"); + + b.Property("MessageId") + .HasColumnType("uniqueidentifier"); + + b.Property("ReceiveCount") + .HasColumnType("int"); + + b.Property("Received") + .HasColumnType("datetime2"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("Id"); + + b.HasAlternateKey("MessageId", "ConsumerId"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ConversationId") + .HasColumnType("uniqueidentifier"); + + b.Property("CorrelationId") + .HasColumnType("uniqueidentifier"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EnqueueTime") + .HasColumnType("datetime2"); + + b.Property("ExpirationTime") + .HasColumnType("datetime2"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Headers") + .HasColumnType("nvarchar(max)"); + + b.Property("InboxConsumerId") + .HasColumnType("uniqueidentifier"); + + b.Property("InboxMessageId") + .HasColumnType("uniqueidentifier"); + + b.Property("InitiatorId") + .HasColumnType("uniqueidentifier"); + + b.Property("MessageId") + .HasColumnType("uniqueidentifier"); + + b.Property("OutboxId") + .HasColumnType("uniqueidentifier"); + + b.Property("Properties") + .HasColumnType("nvarchar(max)"); + + b.Property("RequestId") + .HasColumnType("uniqueidentifier"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("SentTime") + .HasColumnType("datetime2"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique() + .HasFilter("[OutboxId] IS NOT NULL"); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique() + .HasFilter("[InboxMessageId] IS NOT NULL AND [InboxConsumerId] IS NOT NULL"); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("Created") + .HasColumnType("datetime2"); + + b.Property("Delivered") + .HasColumnType("datetime2"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uniqueidentifier"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("rowversion"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { b.Property("Id") diff --git a/src/Gateway/DependencyInjection.cs b/src/Gateway/DependencyInjection.cs index 27632f1..74e0355 100644 --- a/src/Gateway/DependencyInjection.cs +++ b/src/Gateway/DependencyInjection.cs @@ -55,11 +55,13 @@ static IServiceCollection AddEventBus(this IServiceCollection services, IConfigu return services.AddMassTransit(bus => { + bus.SetKebabCaseEndpointNameFormatter(); + bus - .AddAccountConsumers() + .ConfigureAccountBus() .ConfigureHealthCheckOptions(o => o.Name = "rabbitmq") .AddConfigureEndpointsCallback((name, cfg) => cfg.UseMessageRetry(r => r.Interval(3, TimeSpan.FromSeconds(5)))); - + bus.UsingRabbitMq((ctx, cfg) => { EventBusOptions options = ctx.GetRequiredService>().Value; diff --git a/src/Gateway/Modules/AccountModule.cs b/src/Gateway/Modules/AccountModule.cs index 2d65af2..cba2046 100644 --- a/src/Gateway/Modules/AccountModule.cs +++ b/src/Gateway/Modules/AccountModule.cs @@ -26,7 +26,15 @@ public override void AddRoutes(IEndpointRouteBuilder app) .Produces(StatusCodes.Status201Created) .Produces(StatusCodes.Status422UnprocessableEntity) .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status500InternalServerError); + .Produces(StatusCodes.Status500InternalServerError) + .WithOpenApi(e => + { + e.Responses["201"].Description = "The account was opened successfully and is pending email confirmation."; + e.Responses["422"].Description = "Policy violation, such as the user being underage or not accepting the terms."; + e.Responses["400"].Description = "Invalid input, such as a malformed e-mail or document number."; + e.Responses["500"].Description = "An unexpected server error occurred."; + return e; + }); app.MapPatch("/confirm-email", ([FromBody] ConfirmEmailCommand command) => @@ -35,6 +43,14 @@ public override void AddRoutes(IEndpointRouteBuilder app) .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status422UnprocessableEntity) .Produces(StatusCodes.Status400BadRequest) - .Produces(StatusCodes.Status500InternalServerError); + .Produces(StatusCodes.Status500InternalServerError) + .WithOpenApi(e => + { + e.Responses["200"].Description = "E-mail successfully confirmed."; + e.Responses["422"].Description = "Policy violation, e.g., expired token or the account does not exist."; + e.Responses["400"].Description = "Invalid input, such as an invalid account number"; + e.Responses["500"].Description = "An unexpected server error occurred."; + return e; + }); } } \ No newline at end of file diff --git a/src/Shared/Kairos.Shared.csproj b/src/Shared/Kairos.Shared.csproj index 1ed2d2d..8d75705 100644 --- a/src/Shared/Kairos.Shared.csproj +++ b/src/Shared/Kairos.Shared.csproj @@ -10,6 +10,7 @@ + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs b/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs index 294da61..944ea0d 100644 --- a/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs +++ b/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs @@ -1,8 +1,11 @@ using FluentAssertions; using Kairos.Account.Business.UseCases; +using Kairos.Account.Domain; +using Kairos.Account.Infra; using Kairos.Shared.Contracts.Account; using Kairos.Shared.Enums; using MassTransit; +using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Logging; using NSubstitute; @@ -14,7 +17,11 @@ public sealed class OpenAccountUseCaseTests readonly ILogger _logger = Substitute.For>(); readonly OpenAccountUseCase _sut; - public OpenAccountUseCaseTests() => _sut = new(_logger, _bus); + public OpenAccountUseCaseTests() => _sut = new( + _logger, + _bus, + Substitute.For>(), + Substitute.For()); [Fact(DisplayName = "Open Account - Happy path")] public async Task OpenAccount_HappyPath() From ed51bfb0e68c487d32468fe31fd05bd4698dd1d9 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Fri, 5 Dec 2025 04:50:26 -0300 Subject: [PATCH 30/34] Password reset endpoint --- .editorconfig | 1 + .../Business/UseCases/ConfirmEmailUseCase.cs | 4 +- .../Business/UseCases/OpenAccountUseCase.cs | 54 ++++++++++------ .../Business/UseCases/SetPasswordUseCase.cs | 64 +++++++++++++++++++ src/Account/DependencyInjection.cs | 3 + .../SendAccountOpenedEmailConsumer.cs | 28 +++++++- src/Gateway/DependencyInjection.cs | 1 - src/Gateway/Kairos.Gateway.csproj | 1 - .../Modules/{ => Account}/AccountModule.cs | 33 +++++++++- .../Account/Request/ConfirmEmailRequest.cs | 3 + .../Account/Request/SetPasswordRequest.cs | 13 ++++ .../Account/OpenAccount/AccountOpened.cs | 3 +- .../Contracts/Account/SetPasswordCommand.cs | 10 +++ 13 files changed, 189 insertions(+), 29 deletions(-) create mode 100644 src/Account/Business/UseCases/SetPasswordUseCase.cs rename src/Gateway/Modules/{ => Account}/AccountModule.cs (58%) create mode 100644 src/Gateway/Modules/Account/Request/ConfirmEmailRequest.cs create mode 100644 src/Gateway/Modules/Account/Request/SetPasswordRequest.cs create mode 100644 src/Shared/Contracts/Account/SetPasswordCommand.cs diff --git a/.editorconfig b/.editorconfig index aa31cf1..b8439ec 100644 --- a/.editorconfig +++ b/.editorconfig @@ -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 \ No newline at end of file diff --git a/src/Account/Business/UseCases/ConfirmEmailUseCase.cs b/src/Account/Business/UseCases/ConfirmEmailUseCase.cs index 6e807f3..90bd27f 100644 --- a/src/Account/Business/UseCases/ConfirmEmailUseCase.cs +++ b/src/Account/Business/UseCases/ConfirmEmailUseCase.cs @@ -66,6 +66,8 @@ async Task ConfirmEmail(ConfirmEmailCommand input) return Output.PolicyViolation(errors); } - return Output.Ok(["E-mail confirmado com sucesso!"]); + return Output.Ok([ + "E-mail confirmado com sucesso!", + "Defina uma senha para acesso à conta."]); } } diff --git a/src/Account/Business/UseCases/OpenAccountUseCase.cs b/src/Account/Business/UseCases/OpenAccountUseCase.cs index 7d5a3e2..ba52f8e 100644 --- a/src/Account/Business/UseCases/OpenAccountUseCase.cs +++ b/src/Account/Business/UseCases/OpenAccountUseCase.cs @@ -35,10 +35,10 @@ public async Task Handle( Investor? existingAccount = await db.Investors .FirstOrDefaultAsync( - i => + i => i.Email == req.Email || i.PhoneNumber == req.PhoneNumber || - i.Document == req.Document, + i.Document == req.Document, cancellationToken); if (existingAccount is not null) @@ -63,8 +63,6 @@ public async Task Handle( Investor investor = openAccountResult.Value!; - // TODO: usar transaction/outbox - var identityResult = await identity.CreateAsync(investor); if (identityResult.Succeeded is false) @@ -76,32 +74,48 @@ public async Task Handle( return Output.PolicyViolation(errors); } - var token = await identity.GenerateEmailConfirmationTokenAsync(investor); - - await bus.Publish( - new AccountOpened( - investor.Id, - req.Name, - req.PhoneNumber, - req.Document, - req.Email, - req.Birthdate, - token, - req.CorrelationId), - ctx => ctx.CorrelationId = req.CorrelationId, - cancellationToken); + await RaiseEvent(req, investor, cancellationToken); logger.LogInformation("Account {AccountId} opened!", investor.Id); return Output.Created([ - $"Conta de investimento {investor.Id} 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); + } } \ No newline at end of file diff --git a/src/Account/Business/UseCases/SetPasswordUseCase.cs b/src/Account/Business/UseCases/SetPasswordUseCase.cs new file mode 100644 index 0000000..3437025 --- /dev/null +++ b/src/Account/Business/UseCases/SetPasswordUseCase.cs @@ -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 identity, + ILogger logger +) : IRequestHandler +{ + public async Task Handle(SetPasswordCommand input, CancellationToken cancellationToken) + { + var enrichers = new Dictionary + { + ["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]); + } + } + } +} \ No newline at end of file diff --git a/src/Account/DependencyInjection.cs b/src/Account/DependencyInjection.cs index 521549a..47a9c0e 100644 --- a/src/Account/DependencyInjection.cs +++ b/src/Account/DependencyInjection.cs @@ -67,6 +67,9 @@ static IServiceCollection AddIdentity( .AddIdentity>(o => { o.Password.RequireDigit = true; + o.Password.RequireNonAlphanumeric = false; + o.Password.RequireLowercase = false; + o.Password.RequireUppercase = false; o.Password.RequiredLength = 6; o.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); diff --git a/src/Account/Infra/Consumers/SendAccountOpenedEmailConsumer.cs b/src/Account/Infra/Consumers/SendAccountOpenedEmailConsumer.cs index 64fb0a0..83eca19 100644 --- a/src/Account/Infra/Consumers/SendAccountOpenedEmailConsumer.cs +++ b/src/Account/Infra/Consumers/SendAccountOpenedEmailConsumer.cs @@ -1,22 +1,46 @@ using Kairos.Shared.Contracts.Account; using MassTransit; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Kairos.Account.Infra.Consumers; public sealed class SendAccountOpenedEmailConsumer( - ILogger logger + ILogger logger, + IConfiguration config ) : IConsumer { public async Task Consume(ConsumeContext context) { + var message = context.Message; + using (logger.BeginScope("AccountId: {AccountId}", context.Message.Id)) { try { logger.LogInformation("Sending opened account confirmation e-mail"); - await Task.Delay(TimeSpan.FromSeconds(5)); + var baseUrl = config["KairosUI:BaseUrl"]; + + if (string.IsNullOrEmpty(baseUrl)) + { + logger.LogError("KairosUI:BaseUrl is not configured. Cannot send confirmation email."); + return; + } + + var confirmationLink = $"{baseUrl}/auth?accountId={message.Id}&token={Uri.EscapeDataString(message.EmailConfirmationToken)}"; + + var emailBody = $"

Welcome to Kairos!

" + + $"

Please confirm your account by clicking here.

"; + + logger.LogInformation( + "Sending confirmation email to {Email} with link {Link}", + message.Email, + confirmationLink); + + // await _emailService.SendAsync(message.Email, "Kairos - Confirm your account", emailBody); + + await Task.Delay(TimeSpan.FromSeconds(3)); logger.LogInformation("Opened account confirmation e-mail sent"); } diff --git a/src/Gateway/DependencyInjection.cs b/src/Gateway/DependencyInjection.cs index 74e0355..ac6050e 100644 --- a/src/Gateway/DependencyInjection.cs +++ b/src/Gateway/DependencyInjection.cs @@ -1,7 +1,6 @@ using System.Text.Json.Serialization; using Carter; using Kairos.Account; -using Kairos.Gateway.Filters; using Kairos.Shared.Configuration; using Kairos.Shared.Contracts; using Mapster; diff --git a/src/Gateway/Kairos.Gateway.csproj b/src/Gateway/Kairos.Gateway.csproj index b150373..c05b316 100644 --- a/src/Gateway/Kairos.Gateway.csproj +++ b/src/Gateway/Kairos.Gateway.csproj @@ -19,7 +19,6 @@ - \ No newline at end of file diff --git a/src/Gateway/Modules/AccountModule.cs b/src/Gateway/Modules/Account/AccountModule.cs similarity index 58% rename from src/Gateway/Modules/AccountModule.cs rename to src/Gateway/Modules/Account/AccountModule.cs index cba2046..874359d 100644 --- a/src/Gateway/Modules/AccountModule.cs +++ b/src/Gateway/Modules/Account/AccountModule.cs @@ -1,4 +1,5 @@ using Carter; +using Kairos.Gateway.Modules.Account.Request; using Kairos.Shared.Contracts.Account; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -36,9 +37,12 @@ public override void AddRoutes(IEndpointRouteBuilder app) return e; }); - app.MapPatch("/confirm-email", - ([FromBody] ConfirmEmailCommand command) => - _mediator.Send(command)) + app.MapPatch("/{id}/confirm-email", + ([FromRoute] long id, [FromBody] ConfirmEmailRequest req) => + _mediator.Send(new ConfirmEmailCommand( + id, + req.ConfirmationToken, + Guid.NewGuid()))) .WithSummary("Account opening e-mail confirmation") .Produces(StatusCodes.Status200OK) .Produces(StatusCodes.Status422UnprocessableEntity) @@ -52,5 +56,28 @@ public override void AddRoutes(IEndpointRouteBuilder app) e.Responses["500"].Description = "An unexpected server error occurred."; return e; }); + + app.MapPatch("/{id}/set-password", + ([FromRoute] long id, [FromBody] SetPasswordRequest req) => + _mediator.Send(new SetPasswordCommand( + id, + req.Pass, + req.PassConfirmation, + req.Token, + Guid.NewGuid()))) + .WithSummary("Defines or resets an account's password") + .WithDescription("A valid password reset token, which comes from e-mail, must be provided.") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status422UnprocessableEntity) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError) + .WithOpenApi(e => + { + e.Responses["200"].Description = "Password successfully (re)defined."; + e.Responses["422"].Description = "Policy violation, e.g., the e-mail is not confirmed."; + e.Responses["400"].Description = "Invalid input, such as missing the pass confirmation."; + e.Responses["500"].Description = "An unexpected server error occurred."; + return e; + }); } } \ No newline at end of file diff --git a/src/Gateway/Modules/Account/Request/ConfirmEmailRequest.cs b/src/Gateway/Modules/Account/Request/ConfirmEmailRequest.cs new file mode 100644 index 0000000..33895a6 --- /dev/null +++ b/src/Gateway/Modules/Account/Request/ConfirmEmailRequest.cs @@ -0,0 +1,3 @@ +namespace Kairos.Gateway.Modules.Account.Request; + +internal sealed record ConfirmEmailRequest(string ConfirmationToken); diff --git a/src/Gateway/Modules/Account/Request/SetPasswordRequest.cs b/src/Gateway/Modules/Account/Request/SetPasswordRequest.cs new file mode 100644 index 0000000..39f5125 --- /dev/null +++ b/src/Gateway/Modules/Account/Request/SetPasswordRequest.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations; + +namespace Kairos.Gateway.Modules.Account.Request; + +internal sealed record SetPasswordRequest( + [Required] + string Pass, + + [Required] + string PassConfirmation, + + [Required] + string Token); diff --git a/src/Shared/Contracts/Account/OpenAccount/AccountOpened.cs b/src/Shared/Contracts/Account/OpenAccount/AccountOpened.cs index d5f8c34..27c7ced 100644 --- a/src/Shared/Contracts/Account/OpenAccount/AccountOpened.cs +++ b/src/Shared/Contracts/Account/OpenAccount/AccountOpened.cs @@ -7,5 +7,6 @@ public sealed record AccountOpened( string Document, string Email, DateTime Birthdate, - string ConfirmationToken, + string EmailConfirmationToken, + string PasswordResetToken, Guid CorrelationId); \ No newline at end of file diff --git a/src/Shared/Contracts/Account/SetPasswordCommand.cs b/src/Shared/Contracts/Account/SetPasswordCommand.cs new file mode 100644 index 0000000..b0608a4 --- /dev/null +++ b/src/Shared/Contracts/Account/SetPasswordCommand.cs @@ -0,0 +1,10 @@ +using Kairos.Shared.Abstractions; + +namespace Kairos.Shared.Contracts.Account; + +public sealed record SetPasswordCommand( + long AccountId, + string Pass, + string PassConfirmation, + string Token, + Guid CorrelationId) : ICommand; \ No newline at end of file From ba6cd7bf8956bccf4dd5fe199976f03321dd09ec Mon Sep 17 00:00:00 2001 From: sagustavo Date: Fri, 5 Dec 2025 05:53:06 -0300 Subject: [PATCH 31/34] Unit tests --- Directory.Packages.props | 1 + src/Account/Domain/Investor.cs | 2 +- src/Account/Infra/AccountContext.cs | 4 +- .../UseCases/ConfirmEmailUseCaseTests.cs | 128 ++++++++++++++ .../UseCases/OpenAccountUseCaseTests.cs | 160 +++++++++++++++--- .../UseCases/SetPasswordUseCaseTests.cs | 129 ++++++++++++++ .../Kairos.Account.UnitTests.csproj | 2 + 7 files changed, 402 insertions(+), 24 deletions(-) create mode 100644 tests/Account.UnitTests/Business/UseCases/ConfirmEmailUseCaseTests.cs create mode 100644 tests/Account.UnitTests/Business/UseCases/SetPasswordUseCaseTests.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 5bc9da9..2417274 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,6 +27,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all
+ diff --git a/src/Account/Domain/Investor.cs b/src/Account/Domain/Investor.cs index 862ae36..759f5fd 100644 --- a/src/Account/Domain/Investor.cs +++ b/src/Account/Domain/Investor.cs @@ -46,7 +46,7 @@ bool acceptTerms return Output.PolicyViolation(["Autorize a coleta de dados para prosseguir."]); } - if (birthdate.AddYears(18) > DateTime.Today) + if (birthdate.AddYears(18) >= DateTime.UtcNow.Date) { return Output.PolicyViolation(["É necessário ser maior de idade para abrir a conta."]); } diff --git a/src/Account/Infra/AccountContext.cs b/src/Account/Infra/AccountContext.cs index 487fa99..19d7df1 100644 --- a/src/Account/Infra/AccountContext.cs +++ b/src/Account/Infra/AccountContext.cs @@ -6,14 +6,14 @@ namespace Kairos.Account.Infra; -internal sealed class AccountContext : IdentityDbContext, long> +internal class AccountContext : IdentityDbContext, long> { public AccountContext(DbContextOptions options) : base(options) { } - public DbSet Investors { get; set; } = null!; + public virtual DbSet Investors { get; set; } = null!; protected override void OnModelCreating(ModelBuilder builder) { diff --git a/tests/Account.UnitTests/Business/UseCases/ConfirmEmailUseCaseTests.cs b/tests/Account.UnitTests/Business/UseCases/ConfirmEmailUseCaseTests.cs new file mode 100644 index 0000000..a9534e3 --- /dev/null +++ b/tests/Account.UnitTests/Business/UseCases/ConfirmEmailUseCaseTests.cs @@ -0,0 +1,128 @@ +using AutoFixture; +using FluentAssertions; +using Kairos.Account.Business.UseCases; +using Kairos.Account.Domain; +using Kairos.Shared.Contracts.Account; +using Kairos.Shared.Enums; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Kairos.Account.UnitTests.Business.UseCases; + +public sealed class ConfirmEmailUseCaseTests +{ + readonly ILogger _logger; + readonly UserManager _identity; + readonly ConfirmEmailUseCase _sut; + readonly Fixture _fixture; + + public ConfirmEmailUseCaseTests() + { + _logger = Substitute.For>(); + _identity = MockUserManager(); + _sut = new ConfirmEmailUseCase(_logger, _identity); + _fixture = new Fixture(); + } + + static UserManager MockUserManager() + { + var store = Substitute.For>(); + return Substitute.For>(store, null, null, null, null, null, null, null, null); + } + + [Fact] + public async Task Handle_ShouldReturnOk_WhenAccountAndTokenAreValid() + { + // Arrange + var command = _fixture.Create(); + + var investor = Investor.OpenAccount("Test", "12378398202", "45692789302", "test@test.com", DateTime.UtcNow.AddYears(-20), true).Value!; + investor.Id = command.AccountId; + + _identity.FindByIdAsync(command.AccountId.ToString()).Returns(investor); + _identity.ConfirmEmailAsync(investor, command.ConfirmationToken).Returns(IdentityResult.Success); + + // Act + var result = await _sut.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.Ok); + result.Messages.Should().Contain("E-mail confirmado com sucesso!"); + await _identity.Received(1).ConfirmEmailAsync(investor, command.ConfirmationToken); + } + + [Theory] + [InlineData(0, "valid-token")] + [InlineData(1, "")] + [InlineData(1, null)] + public async Task Handle_ShouldReturnInvalidInput_WhenCommandIsInvalid(long accountId, string? token) + { + // Arrange + var command = new ConfirmEmailCommand(accountId, token!, Guid.NewGuid()); + + // Act + var result = await _sut.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.InvalidInput); + result.Messages.Should().Contain("A conta e seu token de confirmação devem ser especificados."); + } + + [Fact] + public async Task Handle_ShouldReturnPolicyViolation_WhenAccountNotFound() + { + // Arrange + var command = _fixture.Create(); + _identity.FindByIdAsync(command.AccountId.ToString()).Returns((Investor)null!); + + // Act + var result = await _sut.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.PolicyViolation); + result.Messages.Should().Contain($"A conta {command.AccountId} não existe."); + } + + [Fact] + public async Task Handle_ShouldReturnPolicyViolation_WhenConfirmationFails() + { + // Arrange + var command = _fixture.Create(); + + var investor = Investor.OpenAccount( + "Test", "12378398202", "45692789302", "test@test.com", DateTime.UtcNow.AddYears(-20), true).Value!; + investor.Id = command.AccountId; + + var identityError = new IdentityError { Description = "Invalid token." }; + + _identity.FindByIdAsync(Arg.Any()).Returns(investor); + _identity.ConfirmEmailAsync(investor, command.ConfirmationToken) + .Returns(IdentityResult.Failed(identityError)); + + // Act + var result = await _sut.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.PolicyViolation); + result.Messages.Should().Contain("Invalid token."); + } + + [Fact] + public async Task Handle_ShouldReturnUnexpectedError_WhenIdentityThrowsException() + { + // Arrange + var command = _fixture.Create(); + var exception = new InvalidOperationException("Database error"); + + _identity.FindByIdAsync(command.AccountId.ToString()).ThrowsAsync(exception); + + // Act + var result = await _sut.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.UnexpectedError); + result.Messages.Should().Contain(exception.Message); + } +} \ No newline at end of file diff --git a/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs b/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs index 944ea0d..11d5127 100644 --- a/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs +++ b/tests/Account.UnitTests/Business/UseCases/OpenAccountUseCaseTests.cs @@ -1,3 +1,4 @@ +using AutoFixture; using FluentAssertions; using Kairos.Account.Business.UseCases; using Kairos.Account.Domain; @@ -6,47 +7,164 @@ using Kairos.Shared.Enums; using MassTransit; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using NSubstitute; +using NSubstitute.ExceptionExtensions; namespace Kairos.Account.UnitTests.Business.UseCases; public sealed class OpenAccountUseCaseTests { - readonly IBus _bus = Substitute.For(); - readonly ILogger _logger = Substitute.For>(); + readonly ILogger _logger; + readonly IBus _bus; + readonly UserManager _identity; + readonly AccountContext _db; readonly OpenAccountUseCase _sut; + readonly Fixture _fixture; - public OpenAccountUseCaseTests() => _sut = new( - _logger, - _bus, - Substitute.For>(), - Substitute.For()); + public OpenAccountUseCaseTests() + { + _logger = Substitute.For>(); + _bus = Substitute.For(); + _identity = MockUserManager(); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _db = new AccountContext(options); + + _sut = new OpenAccountUseCase(_logger, _bus, _identity, _db); + + _fixture = new Fixture(); + _fixture.Customize(composer => + composer.With(c => c.AcceptTerms, true) + .With(c => c.Birthdate, DateTime.UtcNow.AddYears(-19)) + .With(c => c.Email, "gustax.dev@outlook.com") + .With(c => c.PhoneNumber, "11994977777") + .With(c => c.Document, "77283890273")); + } - [Fact(DisplayName = "Open Account - Happy path")] - public async Task OpenAccount_HappyPath() + private static UserManager MockUserManager() { - // Arrange - var ct = TestContext.Current.CancellationToken; + var store = Substitute.For>(); + var userManager = Substitute.For>(store, null, null, null, null, null, null, null, null); + + userManager.CreateAsync(Arg.Any()).Returns(IdentityResult.Success); + + userManager.GenerateEmailConfirmationTokenAsync(Arg.Any()).Returns("email-token"); + userManager.GeneratePasswordResetTokenAsync(Arg.Any()).Returns("password-token"); + + return userManager; + } - OpenAccountCommand command = new( - "Foo", - "Bar", - "51625637263", - "foo.bar@baz.com", - DateTime.Today, - true, - Guid.NewGuid()); + [Fact] + public async Task Handle_ShouldReturnCreated_WhenCommandIsValidAndIdentifiersAreUnique() + { + // Arrange + var command = _fixture.Build() + .With(c => c.AcceptTerms, true) + .With(c => c.Birthdate, DateTime.UtcNow.AddYears(-19)) + .With(c => c.Email, "gustax.dev@outlook.com") + .With(c => c.PhoneNumber, "11994977777") + .With(c => c.Document, "77283890273") + .Create(); // Act - var output = await _sut.Handle(command, ct); + var result = await _sut.Handle(command, default); // Assert - output.Status.Should().Be(OutputStatus.Created); + result.Status.Should().Be(OutputStatus.Created); + result.Messages.Should().Contain(m => m.Contains("aberta")); + await _identity.Received(1).CreateAsync(Arg.Is(i => i.Email == command.Email)); await _bus.Received().Publish( Arg.Is(e => e.Document == command.Document), Arg.Any>>(), cancellationToken: Arg.Any()); } + + [Fact] + public async Task Handle_ShouldReturnPolicyViolation_WhenEmailIsAlreadyInUse() + { + // Arrange + var openAccountResult = Investor.OpenAccount( + "Existing User", "55388899922", "11999998888", "taken@email.com", + DateTime.UtcNow.AddYears(-30), true); + var existingInvestor = openAccountResult.Value!; + + _db.Investors.Add(existingInvestor); + await _db.SaveChangesAsync(); + + var command = _fixture.Build() + .With(c => c.Email, "taken@email.com") + .Create(); + + // Act + var result = await _sut.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.PolicyViolation); + result.Messages.Should().Contain("O e-mail, telefone e/ou documento já está(ão) em uso."); + + await _identity.DidNotReceive().CreateAsync(Arg.Any()); + await _bus.DidNotReceiveWithAnyArgs().Publish(default!); + } + + + [Fact] + public async Task Handle_ShouldReturnPolicyViolation_WhenUserIsUnderage() + { + // Arrange + var command = _fixture.Build() + .With(c => c.Birthdate, DateTime.UtcNow.AddYears(-17)) + .With(c => c.AcceptTerms, true) + .Create(); + + // Act + var result = await _sut.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.PolicyViolation); + result.Messages.Should().Contain("É necessário ser maior de idade para abrir a conta."); + } + + [Fact] + public async Task Handle_ShouldReturnPolicyViolation_WhenIdentityCreationFails() + { + // Arrange + var command = _fixture.Create(); + + var identityError = new IdentityError { Description = "Password is too weak." }; + _identity.CreateAsync(Arg.Any()).Returns(IdentityResult.Failed(identityError)); + + // Act + var result = await _sut.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.PolicyViolation); + result.Messages.Should().Contain("Password is too weak."); + + await _bus.DidNotReceiveWithAnyArgs().Publish(default!); + } + + [Fact] + public async Task Handle_ShouldReturnUnexpectedError_WhenDatabaseThrowsException() + { + // Arrange + var command = _fixture.Create(); + + var options = new DbContextOptionsBuilder().Options; + var dbContextMock = Substitute.For(options); + dbContextMock.Investors.Returns(_ => { throw new Exception("Database connection failed"); }); + + var sutWithBadDb = new OpenAccountUseCase(_logger, _bus, _identity, dbContextMock); + + // Act + var result = await sutWithBadDb.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.UnexpectedError); + result.Messages.Should().Contain("Database connection failed"); + } } \ No newline at end of file diff --git a/tests/Account.UnitTests/Business/UseCases/SetPasswordUseCaseTests.cs b/tests/Account.UnitTests/Business/UseCases/SetPasswordUseCaseTests.cs new file mode 100644 index 0000000..c1bd1e9 --- /dev/null +++ b/tests/Account.UnitTests/Business/UseCases/SetPasswordUseCaseTests.cs @@ -0,0 +1,129 @@ +using AutoFixture; +using FluentAssertions; +using Kairos.Account.Business.UseCases; +using Kairos.Account.Domain; +using Kairos.Shared.Contracts.Account; +using Kairos.Shared.Enums; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Kairos.Account.UnitTests.Business.UseCases; + +public sealed class SetPasswordUseCaseTests +{ + readonly ILogger _logger; + readonly UserManager _identity; + readonly SetPasswordUseCase _sut; + readonly Fixture _fixture; + + public SetPasswordUseCaseTests() + { + _logger = Substitute.For>(); + _identity = MockUserManager(); + _sut = new SetPasswordUseCase(_identity, _logger); + _fixture = new Fixture(); + } + + private static UserManager MockUserManager() + { + var store = Substitute.For>(); + return Substitute.For>(store, null, null, null, null, null, null, null, null); + } + + [Fact] + public async Task Handle_ShouldReturnOk_WhenCommandIsValid() + { + // Arrange + var command = _fixture.Create(); + var investor = Investor.OpenAccount("Test", "12345678901", "11987654321", "test@test.com", DateTime.UtcNow.AddYears(-25), true).Value!; + investor.Id = command.AccountId; + + _identity.FindByIdAsync(command.AccountId.ToString()).Returns(investor); + _identity.IsEmailConfirmedAsync(investor).Returns(true); + _identity.ResetPasswordAsync(investor, command.Token, command.Pass).Returns(IdentityResult.Success); + + // Act + var result = await _sut.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.Ok); + result.Messages.Should().Contain("Senha definida com sucesso!"); + await _identity.Received(1).ResetPasswordAsync(investor, command.Token, command.Pass); + } + + [Fact] + public async Task Handle_ShouldReturnPolicyViolation_WhenAccountNotFound() + { + // Arrange + var command = _fixture.Create(); + _identity.FindByIdAsync(command.AccountId.ToString()).Returns((Investor)null!); + + // Act + var result = await _sut.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.PolicyViolation); + result.Messages.Should().Contain($"A conta #{command.AccountId} não existe."); + } + + [Fact] + public async Task Handle_ShouldReturnPolicyViolation_WhenEmailIsNotConfirmed() + { + // Arrange + var command = _fixture.Create(); + var investor = Investor.OpenAccount("Test", "12345678901", "11987654321", "test@test.com", DateTime.UtcNow.AddYears(-25), true).Value!; + investor.Id = command.AccountId; + + _identity.FindByIdAsync(command.AccountId.ToString()).Returns(investor); + _identity.IsEmailConfirmedAsync(investor).Returns(false); + + // Act + var result = await _sut.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.PolicyViolation); + result.Messages.Should().Contain("O e-mail da conta precisa ser confirmado primeiro."); + await _identity.DidNotReceive().ResetPasswordAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_ShouldReturnPolicyViolation_WhenTokenIsInvalid() + { + // Arrange + var command = _fixture.Create(); + var investor = Investor.OpenAccount("Test", "12345678901", "11987654321", "test@test.com", DateTime.UtcNow.AddYears(-25), true).Value!; + investor.Id = command.AccountId; + var identityError = new IdentityError { Description = "Invalid token." }; + + _identity.FindByIdAsync(command.AccountId.ToString()).Returns(investor); + _identity.IsEmailConfirmedAsync(investor).Returns(true); + _identity.ResetPasswordAsync(investor, command.Token, command.Pass) + .Returns(IdentityResult.Failed(identityError)); + + // Act + var result = await _sut.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.PolicyViolation); + result.Messages.Should().Contain("Invalid token."); + } + + [Fact] + public async Task Handle_ShouldReturnUnexpectedError_WhenIdentityThrowsException() + { + // Arrange + var command = _fixture.Create(); + var exception = new InvalidOperationException("Database connection failed"); + + _identity.FindByIdAsync(command.AccountId.ToString()).ThrowsAsync(exception); + + // Act + var result = await _sut.Handle(command, default); + + // Assert + result.Status.Should().Be(OutputStatus.UnexpectedError); + result.Messages.Should().Contain(exception.Message); + } +} \ No newline at end of file diff --git a/tests/Account.UnitTests/Kairos.Account.UnitTests.csproj b/tests/Account.UnitTests/Kairos.Account.UnitTests.csproj index 5790192..9e75038 100644 --- a/tests/Account.UnitTests/Kairos.Account.UnitTests.csproj +++ b/tests/Account.UnitTests/Kairos.Account.UnitTests.csproj @@ -20,6 +20,8 @@ + + From 8aaead13fba2329a7c5e4b06c3bbcbbfa2f0c369 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Fri, 5 Dec 2025 12:44:02 -0300 Subject: [PATCH 32/34] Sign in endpoint --- .../Business/UseCases/AccessAccountUseCase.cs | 122 ++++++++++++++++++ src/Account/Configuration/JwtOptions.cs | 10 ++ src/Account/Configuration/Settings.cs | 6 + src/Account/DependencyInjection.cs | 4 + src/Account/Kairos.Account.csproj | 1 + src/Gateway/DependencyInjection.cs | 5 +- src/Gateway/Modules/Account/AccountModule.cs | 41 ++++++ src/Gateway/Program.cs | 2 + src/Gateway/appsettings.json | 7 + src/MarketData/Configuration/Settings.cs | 2 +- src/Shared/Abstractions/ICommand.cs | 2 +- .../AccessAccount/AccessAccountCommand.cs | 14 ++ .../AccessAccount/AccountIdentifier.cs | 9 ++ 13 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 src/Account/Business/UseCases/AccessAccountUseCase.cs create mode 100644 src/Account/Configuration/JwtOptions.cs create mode 100644 src/Account/Configuration/Settings.cs create mode 100644 src/Shared/Contracts/Account/AccessAccount/AccessAccountCommand.cs create mode 100644 src/Shared/Contracts/Account/AccessAccount/AccountIdentifier.cs diff --git a/src/Account/Business/UseCases/AccessAccountUseCase.cs b/src/Account/Business/UseCases/AccessAccountUseCase.cs new file mode 100644 index 0000000..10a4c3d --- /dev/null +++ b/src/Account/Business/UseCases/AccessAccountUseCase.cs @@ -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 config, + ILogger logger, + SignInManager identity, + AccountContext db +) : IRequestHandler> +{ + readonly JwtOptions _settings = config.Value.Jwt; + + public async Task> Handle( + AccessAccountCommand input, + CancellationToken cancellationToken) + { + var enrichers = new Dictionary + { + ["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.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.PolicyViolation(["Esta conta está bloqueada. Tente novamente após 5 minutos."]); + } + + if (result.IsNotAllowed) + { + logger.LogWarning("Sign-in failed. Email not confirmed."); + return Output.PolicyViolation(["Confirme seu e-mail antes de acessar a conta."]); + } + + if (result.Succeeded is false) + { + logger.LogWarning("Sign-in failed. Invalid password."); + return Output.PolicyViolation(["Identificador ou senha inválidos."]); + } + + logger.LogInformation("Sign-in successful. Generating token."); + + var token = GenerateJwtToken(account); + + return Output.Ok(token, ["Autenticação realizada com sucesso!"]); + } + catch (Exception ex) + { + logger.LogError(ex, "An unexpected error occurred during sign-in."); + return Output.UnexpectedError([ex.Message]); + } + } + } + + string GenerateJwtToken(Investor account) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.ASCII.GetBytes(_settings.Secret); + + var claims = new List + { + 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); + } +} diff --git a/src/Account/Configuration/JwtOptions.cs b/src/Account/Configuration/JwtOptions.cs new file mode 100644 index 0000000..15bf5dc --- /dev/null +++ b/src/Account/Configuration/JwtOptions.cs @@ -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; } +} \ No newline at end of file diff --git a/src/Account/Configuration/Settings.cs b/src/Account/Configuration/Settings.cs new file mode 100644 index 0000000..f98e3a1 --- /dev/null +++ b/src/Account/Configuration/Settings.cs @@ -0,0 +1,6 @@ +namespace Kairos.Account.Configuration; + +internal partial class Settings +{ + public required JwtOptions Jwt { get; init; } +} \ No newline at end of file diff --git a/src/Account/DependencyInjection.cs b/src/Account/DependencyInjection.cs index 47a9c0e..b28c169 100644 --- a/src/Account/DependencyInjection.cs +++ b/src/Account/DependencyInjection.cs @@ -1,4 +1,5 @@ using System.Reflection; +using Kairos.Account.Configuration; using Kairos.Account.Domain; using Kairos.Account.Infra; using Kairos.Account.Infra.Consumers; @@ -17,6 +18,8 @@ public static IServiceCollection AddAccount( this IServiceCollection services, IConfigurationManager config) { + services.Configure(config); + return services .AddIdentity(config) .AddMediatR(cfg => @@ -81,6 +84,7 @@ static IServiceCollection AddIdentity( o.User.RequireUniqueEmail = true; }) .AddEntityFrameworkStores() + .AddSignInManager() .AddDefaultTokenProviders(); return services; diff --git a/src/Account/Kairos.Account.csproj b/src/Account/Kairos.Account.csproj index d2ba90f..19d56d1 100644 --- a/src/Account/Kairos.Account.csproj +++ b/src/Account/Kairos.Account.csproj @@ -7,6 +7,7 @@ + diff --git a/src/Gateway/DependencyInjection.cs b/src/Gateway/DependencyInjection.cs index ac6050e..2bfdd53 100644 --- a/src/Gateway/DependencyInjection.cs +++ b/src/Gateway/DependencyInjection.cs @@ -20,6 +20,7 @@ public static IServiceCollection AddGateway( IConfiguration configuration) { services + .AddAuthorization() .AddHealthChecksUI(options => { options.SetEvaluationTimeInSeconds(30); @@ -27,7 +28,9 @@ public static IServiceCollection AddGateway( }) .AddInMemoryStorage(); - services.AddCarter(); + services + .AddCarter() + .AddAuthentication(); return services .AddMapper() diff --git a/src/Gateway/Modules/Account/AccountModule.cs b/src/Gateway/Modules/Account/AccountModule.cs index 874359d..b39f14e 100644 --- a/src/Gateway/Modules/Account/AccountModule.cs +++ b/src/Gateway/Modules/Account/AccountModule.cs @@ -1,8 +1,11 @@ using Carter; +using Kairos.Account.Configuration; using Kairos.Gateway.Modules.Account.Request; +using Kairos.Shared.Contracts; using Kairos.Shared.Contracts.Account; using MediatR; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; using Response = Kairos.Gateway.Filters.Response; namespace Kairos.Gateway.Modules; @@ -79,5 +82,43 @@ public override void AddRoutes(IEndpointRouteBuilder app) e.Responses["500"].Description = "An unexpected server error occurred."; return e; }); + + app.MapPost("/access", + async ( + [FromBody] AccessAccountCommand command, + HttpContext ctx, + IOptions accountSettings) => + { + Output output = await _mediator.Send(command); + + if (output.IsFailure) + { + return output; + } + + var jwt = accountSettings.Value.Jwt; + + ctx.Response.Cookies.Append(jwt.CookieName, output.Value!, new CookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Strict, + Expires = DateTime.UtcNow.AddMinutes(jwt.ExpiryMinutes) + }); + + return output; + }) + .WithSummary("Access an account") + .Produces(StatusCodes.Status200OK) + .Produces(StatusCodes.Status422UnprocessableEntity) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError) + .WithOpenApi(e => + { + e.Responses["200"].Description = "Authentication successful. The JWT is in the 'data' field."; + e.Responses["422"].Description = "Policy violation, e.g., invalid credentials, locked out, or unconfirmed e-mail."; + e.Responses["400"].Description = "Invalid input, such as a malformed e-mail."; + e.Responses["500"].Description = "An unexpected server error occurred."; + return e; + }); } } \ No newline at end of file diff --git a/src/Gateway/Program.cs b/src/Gateway/Program.cs index 5f3ca4c..fe51118 100644 --- a/src/Gateway/Program.cs +++ b/src/Gateway/Program.cs @@ -45,6 +45,8 @@ app .UseRouting() + .UseAuthentication() + .UseAuthorization() .UseStaticFiles() .UseHealthChecks("/health/ready", new HealthCheckOptions { diff --git a/src/Gateway/appsettings.json b/src/Gateway/appsettings.json index f539ec8..bf20945 100644 --- a/src/Gateway/appsettings.json +++ b/src/Gateway/appsettings.json @@ -23,6 +23,13 @@ "ConnectionString": "Database:MarketData:ConnectionString" } }, + "Jwt": { + "CookieName": "kairos-token", + "Secret": "Jwt:Secret", + "Issuer": "kairos-broker-api", + "Audience": "kairos-broker-ui", + "ExpiryMinutes": 120 + }, "Serilog": { "MinimumLevel": { "Default": "Information", diff --git a/src/MarketData/Configuration/Settings.cs b/src/MarketData/Configuration/Settings.cs index 02614a2..64cafd3 100644 --- a/src/MarketData/Configuration/Settings.cs +++ b/src/MarketData/Configuration/Settings.cs @@ -2,7 +2,7 @@ namespace Kairos.MarketData.Configuration; -internal static partial class Settings +internal partial class Settings { public sealed partial class Api { diff --git a/src/Shared/Abstractions/ICommand.cs b/src/Shared/Abstractions/ICommand.cs index 8ecc7de..4902473 100644 --- a/src/Shared/Abstractions/ICommand.cs +++ b/src/Shared/Abstractions/ICommand.cs @@ -14,4 +14,4 @@ public interface ICommand : IRequest /// /// Represents a command that returns a result /// -public interface ICommand : IRequest>, ICommand; +public interface ICommand : IRequest>; \ No newline at end of file diff --git a/src/Shared/Contracts/Account/AccessAccount/AccessAccountCommand.cs b/src/Shared/Contracts/Account/AccessAccount/AccessAccountCommand.cs new file mode 100644 index 0000000..adc7ae7 --- /dev/null +++ b/src/Shared/Contracts/Account/AccessAccount/AccessAccountCommand.cs @@ -0,0 +1,14 @@ +using Kairos.Shared.Abstractions; +using Kairos.Shared.Contracts.Account.AccessAccount; + +namespace Kairos.Shared.Contracts; + +public sealed record AccessAccountCommand( + Identifier Identifier, + string Password, + Guid CorrelationId +) : ICommand; + +public sealed record Identifier( + string Value, + AccountIdentifier Type); \ No newline at end of file diff --git a/src/Shared/Contracts/Account/AccessAccount/AccountIdentifier.cs b/src/Shared/Contracts/Account/AccessAccount/AccountIdentifier.cs new file mode 100644 index 0000000..9110170 --- /dev/null +++ b/src/Shared/Contracts/Account/AccessAccount/AccountIdentifier.cs @@ -0,0 +1,9 @@ +namespace Kairos.Shared.Contracts.Account.AccessAccount; + +public enum AccountIdentifier +{ + AccountNumber = 1, + Document, + Email, + PhoneNumber, +} \ No newline at end of file From 6c68c0b7c748622e81a98ea331f35fe733490da7 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Fri, 5 Dec 2025 14:03:55 -0300 Subject: [PATCH 33/34] Get Account Info endpoint --- Directory.Packages.props | 1 + .../UseCases/GetAccountInfoUseCase.cs | 47 +++++++++++++++++++ src/Account/DependencyInjection.cs | 46 ++++++++++++++++++ src/Account/Domain/Investor.cs | 2 +- .../{ => Infra}/Configuration/JwtOptions.cs | 0 .../{ => Infra}/Configuration/Settings.cs | 0 src/Account/Kairos.Account.csproj | 1 + src/Gateway/DependencyInjection.cs | 5 +- src/Gateway/Filters/ResponseFormatter.cs | 1 + src/Gateway/Modules/Account/AccountModule.cs | 30 ++++++++++++ .../Configuration/BrapiHealthCheck.cs | 0 .../{ => Infra}/Configuration/Settings.cs | 0 .../Contracts/Account}/Gender.cs | 0 .../Account/GetAccountInfo/AccountInfo.cs | 14 ++++++ .../GetAccountInfo/GetAccountInfoQuery.cs | 11 +++++ .../Contracts/Account}/PersonType.cs | 0 src/Shared/Contracts/Output.cs | 3 ++ src/Shared/Contracts/OutputStatus.cs | 5 ++ 18 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 src/Account/Business/UseCases/GetAccountInfoUseCase.cs rename src/Account/{ => Infra}/Configuration/JwtOptions.cs (100%) rename src/Account/{ => Infra}/Configuration/Settings.cs (100%) rename src/MarketData/{ => Infra}/Configuration/BrapiHealthCheck.cs (100%) rename src/MarketData/{ => Infra}/Configuration/Settings.cs (100%) rename src/{Account/Domain/Enum => Shared/Contracts/Account}/Gender.cs (100%) create mode 100644 src/Shared/Contracts/Account/GetAccountInfo/AccountInfo.cs create mode 100644 src/Shared/Contracts/Account/GetAccountInfo/GetAccountInfoQuery.cs rename src/{Account/Domain/Enum => Shared/Contracts/Account}/PersonType.cs (100%) diff --git a/Directory.Packages.props b/Directory.Packages.props index 2417274..fdb9ec4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,6 +20,7 @@ + diff --git a/src/Account/Business/UseCases/GetAccountInfoUseCase.cs b/src/Account/Business/UseCases/GetAccountInfoUseCase.cs new file mode 100644 index 0000000..51f3b59 --- /dev/null +++ b/src/Account/Business/UseCases/GetAccountInfoUseCase.cs @@ -0,0 +1,47 @@ +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 logger, + AccountContext db) : IRequestHandler> +{ + public async Task> 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.PolicyViolation(["Conta não encontrada."]); + } + + return Output.Ok(new AccountInfo( + account.Id, + account.Name, + account.Birthdate, + account.Gender, + account.PhoneNumber ?? string.Empty, + account.Document, + account.Email!, + Address: string.Empty + )); + } + catch (Exception ex) + { + logger.LogError(ex, "An unexpected error occurred."); + return Output.UnexpectedError([ + "Um erro inesperado ocorreu...", + ex.Message]); + } + } +} diff --git a/src/Account/DependencyInjection.cs b/src/Account/DependencyInjection.cs index b28c169..7544cb8 100644 --- a/src/Account/DependencyInjection.cs +++ b/src/Account/DependencyInjection.cs @@ -1,14 +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; @@ -21,6 +25,7 @@ public static IServiceCollection AddAccount( services.Configure(config); return services + .AddAuth() .AddIdentity(config) .AddMediatR(cfg => { @@ -89,4 +94,45 @@ static IServiceCollection AddIdentity( return services; } + + static IServiceCollection AddAuth(this IServiceCollection services) + { + var jwt = services + .BuildServiceProvider() + .GetRequiredService>() + .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; + } } diff --git a/src/Account/Domain/Investor.cs b/src/Account/Domain/Investor.cs index 759f5fd..8e49c71 100644 --- a/src/Account/Domain/Investor.cs +++ b/src/Account/Domain/Investor.cs @@ -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( diff --git a/src/Account/Configuration/JwtOptions.cs b/src/Account/Infra/Configuration/JwtOptions.cs similarity index 100% rename from src/Account/Configuration/JwtOptions.cs rename to src/Account/Infra/Configuration/JwtOptions.cs diff --git a/src/Account/Configuration/Settings.cs b/src/Account/Infra/Configuration/Settings.cs similarity index 100% rename from src/Account/Configuration/Settings.cs rename to src/Account/Infra/Configuration/Settings.cs diff --git a/src/Account/Kairos.Account.csproj b/src/Account/Kairos.Account.csproj index 19d56d1..4cbda96 100644 --- a/src/Account/Kairos.Account.csproj +++ b/src/Account/Kairos.Account.csproj @@ -11,6 +11,7 @@ + diff --git a/src/Gateway/DependencyInjection.cs b/src/Gateway/DependencyInjection.cs index 2bfdd53..ac6050e 100644 --- a/src/Gateway/DependencyInjection.cs +++ b/src/Gateway/DependencyInjection.cs @@ -20,7 +20,6 @@ public static IServiceCollection AddGateway( IConfiguration configuration) { services - .AddAuthorization() .AddHealthChecksUI(options => { options.SetEvaluationTimeInSeconds(30); @@ -28,9 +27,7 @@ public static IServiceCollection AddGateway( }) .AddInMemoryStorage(); - services - .AddCarter() - .AddAuthentication(); + services.AddCarter(); return services .AddMapper() diff --git a/src/Gateway/Filters/ResponseFormatter.cs b/src/Gateway/Filters/ResponseFormatter.cs index 76624ad..ff45d3f 100644 --- a/src/Gateway/Filters/ResponseFormatter.cs +++ b/src/Gateway/Filters/ResponseFormatter.cs @@ -29,6 +29,7 @@ internal sealed class ResponseFormatter(ILogger logger) : IEn OutputStatus.InvalidInput => StatusCodes.Status400BadRequest, OutputStatus.NotFound => StatusCodes.Status404NotFound, OutputStatus.PolicyViolation => StatusCodes.Status422UnprocessableEntity, + OutputStatus.CredentialsRequired => StatusCodes.Status401Unauthorized, _ => StatusCodes.Status500InternalServerError, }; diff --git a/src/Gateway/Modules/Account/AccountModule.cs b/src/Gateway/Modules/Account/AccountModule.cs index b39f14e..7d8c004 100644 --- a/src/Gateway/Modules/Account/AccountModule.cs +++ b/src/Gateway/Modules/Account/AccountModule.cs @@ -1,8 +1,11 @@ +using System.Security.Claims; using Carter; using Kairos.Account.Configuration; +using Kairos.Gateway.Filters; using Kairos.Gateway.Modules.Account.Request; using Kairos.Shared.Contracts; using Kairos.Shared.Contracts.Account; +using Kairos.Shared.Contracts.Account.GetAccountInfo; using MediatR; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; @@ -120,5 +123,32 @@ public override void AddRoutes(IEndpointRouteBuilder app) e.Responses["500"].Description = "An unexpected server error occurred."; return e; }); + + app.MapGet("/me", + async (HttpContext ctx) => + { + var accountIdValue = ctx.User.FindFirstValue(ClaimTypes.NameIdentifier); + + if (long.TryParse(accountIdValue, out var accountId) is false) + { + return Output.CredentialsRequired(["Acesse sua conta para visualizar os dados cadastrais."]); + } + + var output = await _mediator.Send(new GetAccountInfoQuery(accountId)); + + return output; + }) + .RequireAuthorization() + .WithSummary("Get the authenticated account's data") + .Produces>(StatusCodes.Status200OK) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status500InternalServerError) + .WithOpenApi(e => + { + e.Responses["200"].Description = "Returns the account details for the authenticated user."; + e.Responses["401"].Description = "Unauthorized if the auth cookie is missing or invalid."; + e.Responses["500"].Description = "An unexpected server error occurred."; + return e; + }); } } \ No newline at end of file diff --git a/src/MarketData/Configuration/BrapiHealthCheck.cs b/src/MarketData/Infra/Configuration/BrapiHealthCheck.cs similarity index 100% rename from src/MarketData/Configuration/BrapiHealthCheck.cs rename to src/MarketData/Infra/Configuration/BrapiHealthCheck.cs diff --git a/src/MarketData/Configuration/Settings.cs b/src/MarketData/Infra/Configuration/Settings.cs similarity index 100% rename from src/MarketData/Configuration/Settings.cs rename to src/MarketData/Infra/Configuration/Settings.cs diff --git a/src/Account/Domain/Enum/Gender.cs b/src/Shared/Contracts/Account/Gender.cs similarity index 100% rename from src/Account/Domain/Enum/Gender.cs rename to src/Shared/Contracts/Account/Gender.cs diff --git a/src/Shared/Contracts/Account/GetAccountInfo/AccountInfo.cs b/src/Shared/Contracts/Account/GetAccountInfo/AccountInfo.cs new file mode 100644 index 0000000..7fd7ef4 --- /dev/null +++ b/src/Shared/Contracts/Account/GetAccountInfo/AccountInfo.cs @@ -0,0 +1,14 @@ +using Kairos.Account.Domain.Enum; + +namespace Kairos.Shared.Contracts.Account.GetAccountInfo; + +public sealed record AccountInfo( + long Id, + string Name, + DateTime Birthdate, + Gender Gender, + string PhoneNumber, + string Document, + string Email, + string Address +); diff --git a/src/Shared/Contracts/Account/GetAccountInfo/GetAccountInfoQuery.cs b/src/Shared/Contracts/Account/GetAccountInfo/GetAccountInfoQuery.cs new file mode 100644 index 0000000..a789302 --- /dev/null +++ b/src/Shared/Contracts/Account/GetAccountInfo/GetAccountInfoQuery.cs @@ -0,0 +1,11 @@ +using Kairos.Shared.Abstractions; +using Kairos.Shared.Contracts.Account.GetAccountInfo; + +namespace Kairos.Shared.Contracts.Account; + +public sealed record GetAccountInfoQuery( + long Id, + Guid CorrelationId) : IQuery +{ + public GetAccountInfoQuery(long id) : this(id, Guid.NewGuid()) { } +} \ No newline at end of file diff --git a/src/Account/Domain/Enum/PersonType.cs b/src/Shared/Contracts/Account/PersonType.cs similarity index 100% rename from src/Account/Domain/Enum/PersonType.cs rename to src/Shared/Contracts/Account/PersonType.cs diff --git a/src/Shared/Contracts/Output.cs b/src/Shared/Contracts/Output.cs index 2061ab6..9483cfc 100644 --- a/src/Shared/Contracts/Output.cs +++ b/src/Shared/Contracts/Output.cs @@ -45,6 +45,9 @@ public static Output Created(IEnumerable? messages = null) => public static Output UnexpectedError(IEnumerable messages) => new(OutputStatus.UnexpectedError, messages); + public static Output CredentialsRequired(IEnumerable messages) => + new(OutputStatus.CredentialsRequired, messages); + public static Output PolicyViolation(IEnumerable messages) => new(OutputStatus.PolicyViolation, messages); diff --git a/src/Shared/Contracts/OutputStatus.cs b/src/Shared/Contracts/OutputStatus.cs index 249a93f..e9ca343 100644 --- a/src/Shared/Contracts/OutputStatus.cs +++ b/src/Shared/Contracts/OutputStatus.cs @@ -39,5 +39,10 @@ public enum OutputStatus /// Unexpected internal error /// UnexpectedError, + + /// + /// Unauthorized + /// + CredentialsRequired, #endregion } From d3331f014c39bd456d0b998d89594b3ca1b02a3a Mon Sep 17 00:00:00 2001 From: sagustavo Date: Fri, 5 Dec 2025 14:45:40 -0300 Subject: [PATCH 34/34] Sign out endpoint --- .../UseCases/GetAccountInfoUseCase.cs | 3 ++- src/Account/DependencyInjection.cs | 4 ++-- src/Gateway/DependencyInjection.cs | 24 +++++++++++++++++++ src/Gateway/Modules/Account/AccountModule.cs | 17 +++++++++++++ .../Account/GetAccountInfo/AccountInfo.cs | 3 ++- 5 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/Account/Business/UseCases/GetAccountInfoUseCase.cs b/src/Account/Business/UseCases/GetAccountInfoUseCase.cs index 51f3b59..92a96b4 100644 --- a/src/Account/Business/UseCases/GetAccountInfoUseCase.cs +++ b/src/Account/Business/UseCases/GetAccountInfoUseCase.cs @@ -33,7 +33,8 @@ public async Task> Handle(GetAccountInfoQuery input, Cancell account.PhoneNumber ?? string.Empty, account.Document, account.Email!, - Address: string.Empty + Address: null, + ProfilePicUrl: null )); } catch (Exception ex) diff --git a/src/Account/DependencyInjection.cs b/src/Account/DependencyInjection.cs index 7544cb8..a9b3122 100644 --- a/src/Account/DependencyInjection.cs +++ b/src/Account/DependencyInjection.cs @@ -25,13 +25,13 @@ public static IServiceCollection AddAccount( services.Configure(config); return services - .AddAuth() .AddIdentity(config) .AddMediatR(cfg => { cfg.LicenseKey = config["Keys:MediatR"]; cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly()); - }); + }) + .AddAuth(); } public static IBusRegistrationConfigurator ConfigureAccountBus(this IBusRegistrationConfigurator x) diff --git a/src/Gateway/DependencyInjection.cs b/src/Gateway/DependencyInjection.cs index ac6050e..95c16f4 100644 --- a/src/Gateway/DependencyInjection.cs +++ b/src/Gateway/DependencyInjection.cs @@ -83,6 +83,30 @@ static IServiceCollection AddSwagger(this IServiceCollection services) => .AddSwaggerGen(o => { o.SchemaFilter(); + o.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + In = ParameterLocation.Cookie, + Description = "Please enter a valid JWT token", + Name = "kairos-token", + Type = SecuritySchemeType.Http, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + + o.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); o.SwaggerDoc("v1", new OpenApiInfo { diff --git a/src/Gateway/Modules/Account/AccountModule.cs b/src/Gateway/Modules/Account/AccountModule.cs index 7d8c004..f7e71fc 100644 --- a/src/Gateway/Modules/Account/AccountModule.cs +++ b/src/Gateway/Modules/Account/AccountModule.cs @@ -150,5 +150,22 @@ public override void AddRoutes(IEndpointRouteBuilder app) e.Responses["500"].Description = "An unexpected server error occurred."; return e; }); + + app.MapDelete("/exit", + async ( + HttpContext ctx, + IOptions accountSettings) => + { + ctx.Response.Cookies.Delete(accountSettings.Value.Jwt.CookieName); + + return Output.Empty; + }) + .WithSummary("Sign out") + .Produces(StatusCodes.Status204NoContent) + .WithOpenApi(e => + { + e.Responses["204"].Description = "Logged out successfully."; + return e; + }); } } \ No newline at end of file diff --git a/src/Shared/Contracts/Account/GetAccountInfo/AccountInfo.cs b/src/Shared/Contracts/Account/GetAccountInfo/AccountInfo.cs index 7fd7ef4..e8389f2 100644 --- a/src/Shared/Contracts/Account/GetAccountInfo/AccountInfo.cs +++ b/src/Shared/Contracts/Account/GetAccountInfo/AccountInfo.cs @@ -10,5 +10,6 @@ public sealed record AccountInfo( string PhoneNumber, string Document, string Email, - string Address + string? Address, + Uri? ProfilePicUrl );