From 46ac619e09371b7dfcff3ab778f48ff50e688512 Mon Sep 17 00:00:00 2001 From: sagustavo Date: Mon, 1 Dec 2025 12:00:29 -0300 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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 @@ + +