From 8df3cb36abb16222445739ca661b1ca973165c01 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Wed, 7 Oct 2026 19:52:17 -0300 Subject: [PATCH 1/4] feat(services): desativar e reativar pessoa atendida (#157, #158, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expõe POST /api/v1/clients/{id}/deactivate e /reactivate, que mudam a situação da pessoa sem tocar nos dados nem nos contatos e devolvem o mesmo ClientResponse de POST, PUT e GET. - Client.Inactivate() e Client.Reactivate() recusam a transição que já estão (Client.AlreadyInactive, Client.AlreadyActive). O handler responde 200 com o estado atual quando a ação é repetida, sem consultar nem gravar nada. - Desativar consulta IAppointmentRepository.ExistsNotCancelledStartingAfter com o instante da operação: início estritamente futuro e não cancelado bloqueia com 409 Client.HasUpcomingAppointments. Enquanto o módulo de agendamentos (#153) não existe, PendingAppointmentRepository responde "nenhum" e deve ser trocado no DI por quem criar o primeiro agendamento. - Reativar verifica o e-mail contra outra pessoa ativa do tenant: 409 Client.DuplicateEmail, com meta clientId e clientName. Pessoa inativa com o mesmo e-mail não bloqueia. A corrida é decidida pelo índice único parcial e responde Client.SaveFailed (ADR 0048). - IClientRepository.UpdateStatusAsync grava só a coluna Status, sem recriar os contatos como o UpdateAsync faz. - Tipos do frontend regenerados (services-api.d.ts). - ADR 0062 e linha de transição de estado no §10 do ARCHITECTURE. Co-Authored-By: Claude Sonnet 5.5 --- .../shared/api/generated/services-api.d.ts | 200 ++++++++++++++++++ backend/docs/ARCHITECTURE.md | 1 + .../Controllers/ClientsController.cs | 24 +++ .../Abstractions/IAppointmentRepository.cs | 6 + .../Abstractions/IClientRepository.cs | 3 + .../DeactivateClientCommand.cs | 5 + .../DeactivateClientCommandHandler.cs | 74 +++++++ .../DeactivateClientCommandValidator.cs | 14 ++ .../ReactivateClientCommand.cs | 5 + .../ReactivateClientCommandHandler.cs | 81 +++++++ .../ReactivateClientCommandValidator.cs | 14 ++ .../ServicesService.Domain/Entities/Client.cs | 32 +++ .../DependencyInjection.cs | 2 + .../Repositories/ClientRepository.cs | 9 + .../PendingAppointmentRepository.cs | 14 ++ .../ClientPersistenceTests.cs | 124 +++++++++++ .../Clients/ClientStatusTests.cs | 101 +++++++++ .../DeactivateClientCommandHandlerTests.cs | 153 ++++++++++++++ .../DeactivateClientCommandValidatorTests.cs | 27 +++ .../GetClientByIdQueryHandlerTests.cs | 2 +- .../ReactivateClientCommandHandlerTests.cs | 170 +++++++++++++++ .../ReactivateClientCommandValidatorTests.cs | 27 +++ .../UpdateClientCommandHandlerTests.cs | 2 +- .../0062-clients-deactivate-and-reactivate.md | 94 ++++++++ docs/adr/README.md | 5 +- 25 files changed, 1185 insertions(+), 4 deletions(-) create mode 100644 backend/services/services-service/ServicesService.Application/Abstractions/IAppointmentRepository.cs create mode 100644 backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommand.cs create mode 100644 backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandHandler.cs create mode 100644 backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandValidator.cs create mode 100644 backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommand.cs create mode 100644 backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommandHandler.cs create mode 100644 backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommandValidator.cs create mode 100644 backend/services/services-service/ServicesService.Infrastructure/Repositories/PendingAppointmentRepository.cs create mode 100644 backend/services/services-service/ServicesService.Tests/Clients/ClientStatusTests.cs create mode 100644 backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs create mode 100644 backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandValidatorTests.cs create mode 100644 backend/services/services-service/ServicesService.Tests/Clients/ReactivateClient/ReactivateClientCommandHandlerTests.cs create mode 100644 backend/services/services-service/ServicesService.Tests/Clients/ReactivateClient/ReactivateClientCommandValidatorTests.cs create mode 100644 docs/adr/0062-clients-deactivate-and-reactivate.md diff --git a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts index 043cbef0..306c2cc7 100644 --- a/apps/admin-frontend/src/shared/api/generated/services-api.d.ts +++ b/apps/admin-frontend/src/shared/api/generated/services-api.d.ts @@ -743,6 +743,206 @@ export interface paths { patch?: never; trace?: never; }; + '/api/v{version}/clients/{id}/deactivate': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/plain': components['schemas']['ApiResponseOfClientResponse']; + 'application/json': components['schemas']['ApiResponseOfClientResponse']; + 'text/json': components['schemas']['ApiResponseOfClientResponse']; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/plain': components['schemas']['ApiProblemDetails']; + 'application/json': components['schemas']['ApiProblemDetails']; + 'text/json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/plain': components['schemas']['ApiProblemDetails']; + 'application/json': components['schemas']['ApiProblemDetails']; + 'text/json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/plain': components['schemas']['ApiProblemDetails']; + 'application/json': components['schemas']['ApiProblemDetails']; + 'text/json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/v{version}/clients/{id}/reactivate': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + version: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/plain': components['schemas']['ApiResponseOfClientResponse']; + 'application/json': components['schemas']['ApiResponseOfClientResponse']; + 'text/json': components['schemas']['ApiResponseOfClientResponse']; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/plain': components['schemas']['ApiProblemDetails']; + 'application/json': components['schemas']['ApiProblemDetails']; + 'text/json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/plain': components['schemas']['ApiProblemDetails']; + 'application/json': components['schemas']['ApiProblemDetails']; + 'text/json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Conflict */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + 'text/plain': components['schemas']['ApiProblemDetails']; + 'application/json': components['schemas']['ApiProblemDetails']; + 'text/json': components['schemas']['ApiProblemDetails']; + }; + }; + /** @description Internal Server Error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/problem+json': components['schemas']['ApiProblemDetails']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/v{version}/services': { parameters: { query?: never; diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 7fee2e8a..2cc1b99a 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -453,6 +453,7 @@ touches that slice, not in bulk. | Aggregate with children; references to other aggregates | `Client`, `ClientConfiguration` | a navigation to another root filled by an unchecked `SetTags` (`Service.Tags`) | §3, [0049](../../docs/adr/0049-conventions-for-new-backend-slices.md) | | Update, with children replaced as a composition | `UpdateClientCommandHandler.cs`, `Client.Update`: `GetByIdAsync` → `NotFound` → `client.Update(command.ToClientData(), today)` → pre-checks that exclude the aggregate itself → `UpdateAsync` → save | | §3, §4, [0056](../../docs/adr/0056-clients-edit-replaces-contact-composition.md) | | Read by id | `GetClientByIdQueryHandler.cs`, `GetClientByIdQueryValidator.cs`: repository → `NotFound` or `Response.From`; the id rule written inline in the validator | `GetTagByIdQueryHandler.cs`: a message built from the id, no validator | §4, §6, [0060](../../docs/adr/0060-clients-read-by-id-contract.md) | +| State transition: behaviour method, idempotent repeat, pre-check through a port, status-only save | `DeactivateClientCommandHandler.cs`, `ReactivateClientCommandHandler.cs`, `Client.Inactivate` | | §3, §4, [0062](../../docs/adr/0062-clients-deactivate-and-reactivate.md) | | Delete, list, paging | the earlier slices are the only examples; their flow is current (load → `NotFound` → pre-checks → save; paged query + bounded validator + `PagedResult`) minus the rows above | | §4, §6 | | Code style | `CreateClientCommandHandler.cs` | expression-bodied methods with `&&`/ternaries, "what" comments | §8 | diff --git a/backend/services/services-service/ServicesService.Api/Controllers/ClientsController.cs b/backend/services/services-service/ServicesService.Api/Controllers/ClientsController.cs index d64a5c01..810e5c04 100644 --- a/backend/services/services-service/ServicesService.Api/Controllers/ClientsController.cs +++ b/backend/services/services-service/ServicesService.Api/Controllers/ClientsController.cs @@ -4,7 +4,9 @@ using Microsoft.AspNetCore.Mvc; using ServicesService.Application.Clients; using ServicesService.Application.Clients.CreateClient; +using ServicesService.Application.Clients.DeactivateClient; using ServicesService.Application.Clients.GetClientById; +using ServicesService.Application.Clients.ReactivateClient; using ServicesService.Application.Clients.UpdateClient; namespace ServicesService.Api.Controllers; @@ -55,4 +57,26 @@ public async Task Update(Guid id, UpdateClientCommand command, Ca var result = await _dispatcher.Send(command with { ClientId = id }, cancellationToken); return result.ToActionResult(this, client => Ok(client)); } + + [HttpPost("{id:guid}/deactivate")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Deactivate(Guid id, CancellationToken cancellationToken) + { + var result = await _dispatcher.Send(new DeactivateClientCommand(id), cancellationToken); + return result.ToActionResult(this, client => Ok(client)); + } + + [HttpPost("{id:guid}/reactivate")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task Reactivate(Guid id, CancellationToken cancellationToken) + { + var result = await _dispatcher.Send(new ReactivateClientCommand(id), cancellationToken); + return result.ToActionResult(this, client => Ok(client)); + } } diff --git a/backend/services/services-service/ServicesService.Application/Abstractions/IAppointmentRepository.cs b/backend/services/services-service/ServicesService.Application/Abstractions/IAppointmentRepository.cs new file mode 100644 index 00000000..d25a8d3d --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Abstractions/IAppointmentRepository.cs @@ -0,0 +1,6 @@ +namespace ServicesService.Application.Abstractions; + +public interface IAppointmentRepository +{ + Task ExistsNotCancelledStartingAfterAsync(Guid clientId, DateTimeOffset instant, CancellationToken cancellationToken); +} diff --git a/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs b/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs index bd89bbb6..3f1ce3c0 100644 --- a/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs +++ b/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs @@ -6,6 +6,9 @@ public interface IClientRepository Task UpdateAsync(Client client, CancellationToken cancellationToken); + // Writes the status alone: the contacts and the other columns stay as they are, unlike UpdateAsync. + Task UpdateStatusAsync(Client client, CancellationToken cancellationToken); + // excludeClientId ignores the client being edited, so saving a client without changing its CPF isn't a self-conflict. Task FindByCpfAsync(CpfNumber cpf, Guid? excludeClientId, CancellationToken cancellationToken); diff --git a/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommand.cs b/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommand.cs new file mode 100644 index 00000000..4a5f1383 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommand.cs @@ -0,0 +1,5 @@ +using Admin.SharedKernel; + +namespace ServicesService.Application.Clients.DeactivateClient; + +public sealed record DeactivateClientCommand(Guid ClientId) : ICommand; diff --git a/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandHandler.cs new file mode 100644 index 00000000..83468e47 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandHandler.cs @@ -0,0 +1,74 @@ +using Admin.SharedKernel; +using Microsoft.Extensions.Logging; +using ServicesService.Application.Abstractions; + +namespace ServicesService.Application.Clients.DeactivateClient; + +public sealed class DeactivateClientCommandHandler( + IClientRepository clientRepository, + IAppointmentRepository appointmentRepository, + IUnitOfWork unitOfWork, + TimeProvider timeProvider, + ILogger logger) : ICommandHandler +{ + public async Task> Handle(DeactivateClientCommand command, CancellationToken cancellationToken) + { + var client = await clientRepository.GetByIdAsync(command.ClientId, cancellationToken); + if (client is null) + { + return Result.Failure(Error.NotFound("Client.NotFound", "A pessoa não foi encontrada.")); + } + + if (client.Status == ClientStatus.Inactive) + { + return ClientResponse.FromClient(client); + } + + var upcomingAppointmentConflict = await FindUpcomingAppointmentConflictAsync(client, cancellationToken); + if (upcomingAppointmentConflict is { } appointmentError) + { + return Result.Failure(appointmentError); + } + + var inactivateResult = client.Inactivate(); + if (inactivateResult.IsFailure) + { + return Result.Failure(inactivateResult.Error.ToApplicationError()); + } + + await clientRepository.UpdateStatusAsync(client, cancellationToken); + + var saveResult = await unitOfWork.SaveChangesAsync(cancellationToken); + if (saveResult.IsFailure) + { + logger.LogWarning( + "Saving a client failed with {Kind} on {ConstraintName}", + saveResult.Error.Kind, + saveResult.Error.ConstraintName); + + return Result.Failure(Error.Conflict( + "Client.SaveFailed", + "Não foi possível salvar a pessoa. Tente novamente.")); + } + + return ClientResponse.FromClient(client); + } + + private async Task FindUpcomingAppointmentConflictAsync(Client client, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + + var hasUpcomingAppointment = await appointmentRepository.ExistsNotCancelledStartingAfterAsync( + client.Id, + now, + cancellationToken); + if (!hasUpcomingAppointment) + { + return null; + } + + return Error.Conflict( + "Client.HasUpcomingAppointments", + "Não é possível desativar esta pessoa porque ela tem agendamentos futuros que não foram cancelados. Resolva esses agendamentos e tente novamente."); + } +} diff --git a/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandValidator.cs b/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandValidator.cs new file mode 100644 index 00000000..a9051f35 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace ServicesService.Application.Clients.DeactivateClient; + +public sealed class DeactivateClientCommandValidator : AbstractValidator +{ + public DeactivateClientCommandValidator() + { + RuleFor(command => command.ClientId) + .NotEmpty() + .WithErrorCode("Client.IdRequired") + .WithMessage("O id da pessoa é obrigatório."); + } +} diff --git a/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommand.cs b/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommand.cs new file mode 100644 index 00000000..72715e8e --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommand.cs @@ -0,0 +1,5 @@ +using Admin.SharedKernel; + +namespace ServicesService.Application.Clients.ReactivateClient; + +public sealed record ReactivateClientCommand(Guid ClientId) : ICommand; diff --git a/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommandHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommandHandler.cs new file mode 100644 index 00000000..594dce65 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommandHandler.cs @@ -0,0 +1,81 @@ +using Admin.SharedKernel; +using Microsoft.Extensions.Logging; +using ServicesService.Application.Abstractions; + +namespace ServicesService.Application.Clients.ReactivateClient; + +public sealed class ReactivateClientCommandHandler( + IClientRepository clientRepository, + IUnitOfWork unitOfWork, + ILogger logger) : ICommandHandler +{ + public async Task> Handle(ReactivateClientCommand command, CancellationToken cancellationToken) + { + var client = await clientRepository.GetByIdAsync(command.ClientId, cancellationToken); + if (client is null) + { + return Result.Failure(Error.NotFound("Client.NotFound", "A pessoa não foi encontrada.")); + } + + if (client.Status == ClientStatus.Active) + { + return ClientResponse.FromClient(client); + } + + var emailConflict = await FindEmailConflictAsync(client, cancellationToken); + if (emailConflict is { } emailError) + { + return Result.Failure(emailError); + } + + var reactivateResult = client.Reactivate(); + if (reactivateResult.IsFailure) + { + return Result.Failure(reactivateResult.Error.ToApplicationError()); + } + + await clientRepository.UpdateStatusAsync(client, cancellationToken); + + var saveResult = await unitOfWork.SaveChangesAsync(cancellationToken); + if (saveResult.IsFailure) + { + logger.LogWarning( + "Saving a client failed with {Kind} on {ConstraintName}", + saveResult.Error.Kind, + saveResult.Error.ConstraintName); + + return Result.Failure(Error.Conflict( + "Client.SaveFailed", + "Não foi possível salvar a pessoa. Tente novamente.")); + } + + return ClientResponse.FromClient(client); + } + + private async Task FindEmailConflictAsync(Client client, CancellationToken cancellationToken) + { + if (client.Email is null) + { + return null; + } + + var otherActiveClientWithSameEmail = await clientRepository.FindActiveByEmailAsync( + client.Email, + client.Id, + cancellationToken); + if (otherActiveClientWithSameEmail is null) + { + return null; + } + + return Error.Conflict( + "Client.DuplicateEmail", + "Não é possível reativar esta pessoa porque outra pessoa ativa já usa este e-mail. Corrija o e-mail deste cadastro e tente novamente.", + field: "Email", + meta: new Dictionary + { + ["clientId"] = otherActiveClientWithSameEmail.Id.ToString(), + ["clientName"] = otherActiveClientWithSameEmail.FullName.Value, + }); + } +} diff --git a/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommandValidator.cs b/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommandValidator.cs new file mode 100644 index 00000000..d28eb2a2 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommandValidator.cs @@ -0,0 +1,14 @@ +using FluentValidation; + +namespace ServicesService.Application.Clients.ReactivateClient; + +public sealed class ReactivateClientCommandValidator : AbstractValidator +{ + public ReactivateClientCommandValidator() + { + RuleFor(command => command.ClientId) + .NotEmpty() + .WithErrorCode("Client.IdRequired") + .WithMessage("O id da pessoa é obrigatório."); + } +} diff --git a/backend/services/services-service/ServicesService.Domain/Entities/Client.cs b/backend/services/services-service/ServicesService.Domain/Entities/Client.cs index 83630543..42a01102 100644 --- a/backend/services/services-service/ServicesService.Domain/Entities/Client.cs +++ b/backend/services/services-service/ServicesService.Domain/Entities/Client.cs @@ -19,6 +19,14 @@ public class Client : TenantOwnedEntity "Client.GuardianRequired", $"Informe ao menos um responsável para pessoas menores de {BirthDate.AdultAgeInYears} anos."); + public static readonly DomainError AlreadyInactive = new( + "Client.AlreadyInactive", + "A pessoa já está inativa."); + + public static readonly DomainError AlreadyActive = new( + "Client.AlreadyActive", + "A pessoa já está ativa."); + public FullName FullName { get; private set; } public BirthDate? BirthDate { get; private set; } public PhoneNumber? Phone { get; private set; } @@ -127,6 +135,30 @@ public DomainResult Update(ClientData data, DateOnly today) return DomainResult.Success(); } + public DomainResult Inactivate() + { + if (Status == ClientStatus.Inactive) + { + return DomainResult.Failure(AlreadyInactive); + } + + Status = ClientStatus.Inactive; + + return DomainResult.Success(); + } + + public DomainResult Reactivate() + { + if (Status == ClientStatus.Active) + { + return DomainResult.Failure(AlreadyActive); + } + + Status = ClientStatus.Active; + + return DomainResult.Success(); + } + private DomainResult AddContacts( IReadOnlyCollection guardians, IReadOnlyCollection referenceContacts) diff --git a/backend/services/services-service/ServicesService.Infrastructure/DependencyInjection.cs b/backend/services/services-service/ServicesService.Infrastructure/DependencyInjection.cs index 180e8160..0c9f03a2 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/DependencyInjection.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/DependencyInjection.cs @@ -38,6 +38,8 @@ public static IServiceCollection AddServicesInfrastructure( services.AddScoped(); services.AddScoped(); services.AddScoped(); + // #153 replaces this with the real repository; until then no appointment exists, so none can block a deactivation. + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs index 8d451b54..adf049df 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs @@ -37,6 +37,15 @@ public async Task UpdateAsync(Client client, CancellationToken cancellationToken DbContext.Set().AddRange(client.ReferenceContacts); } + public Task UpdateStatusAsync(Client client, CancellationToken cancellationToken) + { + var entry = DbContext.Entry(client); + entry.State = EntityState.Unchanged; + entry.Property(c => c.Status).IsModified = true; + + return Task.CompletedTask; + } + public Task FindByCpfAsync(CpfNumber cpf, Guid? excludeClientId, CancellationToken cancellationToken) { return Set diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/PendingAppointmentRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/PendingAppointmentRepository.cs new file mode 100644 index 00000000..9a1496fa --- /dev/null +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/PendingAppointmentRepository.cs @@ -0,0 +1,14 @@ +using ServicesService.Application.Abstractions; + +namespace ServicesService.Infrastructure.Repositories; + +public class PendingAppointmentRepository : IAppointmentRepository +{ + public Task ExistsNotCancelledStartingAfterAsync( + Guid clientId, + DateTimeOffset instant, + CancellationToken cancellationToken) + { + return Task.FromResult(false); + } +} diff --git a/backend/services/services-service/ServicesService.PersistenceTests/ClientPersistenceTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/ClientPersistenceTests.cs index d0ac2e5e..c9267aad 100644 --- a/backend/services/services-service/ServicesService.PersistenceTests/ClientPersistenceTests.cs +++ b/backend/services/services-service/ServicesService.PersistenceTests/ClientPersistenceTests.cs @@ -583,6 +583,130 @@ public async Task Update_ReplacesEveryContactInOneSave_AndSoftDeletesThePrevious } } + private static async Task ChangeStatusThroughTheRepository( + string databaseName, + Guid tenantId, + Guid clientId, + Func transition) + { + await using var context = CreateContext(databaseName, tenantId); + var repository = new ClientRepository(context); + var loaded = (await repository.GetByIdAsync(clientId, CancellationToken.None))!; + + transition(loaded).IsSuccess.Should().BeTrue(); + await repository.UpdateStatusAsync(loaded, CancellationToken.None); + await context.SaveChangesAsync(CancellationToken.None); + } + + [Fact] + public async Task UpdateStatus_ChangesTheStatusAndKeepsTheSameContactRows() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var client = NewClient( + cpf: CpfDigits, + email: "maria@example.com", + guardians: [Guardian(), Guardian("Bia Souza")], + referenceContacts: [ReferenceContact()]); + await using (var context = CreateContext(databaseName, tenantId)) + { + await Save(context, client); + } + + var guardianIds = client.Guardians.Select(guardian => guardian.Id).ToArray(); + var referenceContactIds = client.ReferenceContacts.Select(contact => contact.Id).ToArray(); + + await ChangeStatusThroughTheRepository(databaseName, tenantId, client.Id, loaded => loaded.Inactivate()); + + await using var verification = CreateContext(databaseName, tenantId); + var stored = await verification.Clients + .Include(c => c.Guardians) + .Include(c => c.ReferenceContacts) + .SingleAsync(c => c.Id == client.Id, TestContext.Current.CancellationToken); + stored.Status.Should().Be(ClientStatus.Inactive); + stored.UpdatedAt.Should().NotBeNull(); + stored.Cpf.Should().Be(Cpf(CpfDigits)); + stored.Email.Should().Be(Email("maria@example.com")); + stored.Guardians.Select(guardian => guardian.Id).Should().BeEquivalentTo(guardianIds); + stored.ReferenceContacts.Select(contact => contact.Id).Should().BeEquivalentTo(referenceContactIds); + (await verification.Set().IgnoreQueryFilters().CountAsync(TestContext.Current.CancellationToken)) + .Should().Be(2, "no contact row is inserted or soft-deleted by a status change"); + (await verification.Set().IgnoreQueryFilters().CountAsync(TestContext.Current.CancellationToken)) + .Should().Be(1); + (await verification.Set().IgnoreQueryFilters().AnyAsync(guardian => guardian.DeletedAt != null, TestContext.Current.CancellationToken)) + .Should().BeFalse(); + } + + [Fact] + public async Task UpdateStatus_LeavesAColumnChangedByAnotherSessionAfterTheLoadUntouched() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var client = NewClient("Maria Souza", guardians: [Guardian()]); + await using (var context = CreateContext(databaseName, tenantId)) + { + await Save(context, client); + } + + await using (var context = CreateContext(databaseName, tenantId)) + { + var repository = new ClientRepository(context); + var loaded = (await repository.GetByIdAsync(client.Id, CancellationToken.None))!; + + await using (var other = CreateContext(databaseName, tenantId)) + { + var tracked = await other.Clients.AsTracking().SingleAsync(c => c.Id == client.Id, TestContext.Current.CancellationToken); + other.Entry(tracked).Property(c => c.FullName).CurrentValue = FullName.Create("Maria Lima").Value; + await other.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + loaded.Inactivate().IsSuccess.Should().BeTrue(); + await repository.UpdateStatusAsync(loaded, CancellationToken.None); + await context.SaveChangesAsync(TestContext.Current.CancellationToken); + } + + await using (var context = CreateContext(databaseName, tenantId)) + { + var stored = await context.Clients.SingleAsync(c => c.Id == client.Id, TestContext.Current.CancellationToken); + stored.Status.Should().Be(ClientStatus.Inactive); + stored.FullName.Value.Should().Be("Maria Lima"); + } + } + + [Fact] + public async Task UpdateStatus_InactivatesAndReactivatesTheSameClient_FreeingAndTakingTheEmailBack() + { + var databaseName = Guid.NewGuid().ToString(); + var tenantId = Guid.NewGuid(); + var client = NewClient(email: "maria@example.com", guardians: [Guardian()]); + await using (var context = CreateContext(databaseName, tenantId)) + { + await Save(context, client); + } + + await ChangeStatusThroughTheRepository(databaseName, tenantId, client.Id, loaded => loaded.Inactivate()); + + await using (var context = CreateContext(databaseName, tenantId)) + { + var repository = new ClientRepository(context); + (await repository.GetByIdAsync(client.Id, CancellationToken.None))!.Status.Should().Be(ClientStatus.Inactive); + (await repository.FindActiveByEmailAsync(Email("maria@example.com"), null, CancellationToken.None)).Should().BeNull(); + } + + await ChangeStatusThroughTheRepository(databaseName, tenantId, client.Id, loaded => loaded.Reactivate()); + + await using (var context = CreateContext(databaseName, tenantId)) + { + var repository = new ClientRepository(context); + var reactivated = (await repository.GetByIdAsync(client.Id, CancellationToken.None))!; + reactivated.Id.Should().Be(client.Id); + reactivated.Status.Should().Be(ClientStatus.Active); + reactivated.Guardians.Should().ContainSingle().Which.Id.Should().Be(client.Guardians.Single().Id); + (await repository.FindActiveByEmailAsync(Email("maria@example.com"), null, CancellationToken.None))!.Id + .Should().Be(client.Id); + } + } + [Fact] public void Model_BacksCpfAndEmailUniquenessWithFilteredUniqueIndexes() { diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ClientStatusTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ClientStatusTests.cs new file mode 100644 index 00000000..0193a527 --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/ClientStatusTests.cs @@ -0,0 +1,101 @@ +namespace ServicesService.Tests.Clients; + +public class ClientStatusTests +{ + private static Client ClientWithContacts() + { + return Client.Create( + ClientTestData.Data( + birthDate: ClientTestData.Birth(new DateOnly(2015, 3, 10)), + email: ClientTestData.Email(), + cpf: ClientTestData.Cpf(), + guardians: [ClientTestData.Guardian("Ana Souza"), ClientTestData.Guardian("Bia Souza")], + referenceContacts: [ClientTestData.ReferenceContact()]), + ClientTestData.Today).Value; + } + + [Fact] + public void Inactivate_OnAnActiveClient_MakesItInactive() + { + var client = ClientWithContacts(); + + var result = client.Inactivate(); + + result.IsSuccess.Should().BeTrue(); + client.Status.Should().Be(ClientStatus.Inactive); + } + + [Fact] + public void Inactivate_KeepsTheDataAndTheContactsOfTheClient() + { + var client = ClientWithContacts(); + var id = client.Id; + var guardianIds = client.Guardians.Select(guardian => guardian.Id).ToArray(); + var referenceContactIds = client.ReferenceContacts.Select(contact => contact.Id).ToArray(); + + client.Inactivate(); + + client.Id.Should().Be(id); + client.FullName.Should().Be(ClientTestData.Name()); + client.Email.Should().Be(ClientTestData.Email()); + client.Cpf.Should().Be(ClientTestData.Cpf()); + client.BirthDate!.Value.Should().Be(new DateOnly(2015, 3, 10)); + client.Guardians.Select(guardian => guardian.Id).Should().Equal(guardianIds); + client.ReferenceContacts.Select(contact => contact.Id).Should().Equal(referenceContactIds); + } + + [Fact] + public void Inactivate_OnAnInactiveClient_IsRefusedWithAlreadyInactive() + { + var client = ClientWithContacts(); + client.Inactivate(); + + var result = client.Inactivate(); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(Client.AlreadyInactive); + result.Error.Code.Should().Be("Client.AlreadyInactive"); + client.Status.Should().Be(ClientStatus.Inactive); + } + + [Fact] + public void Reactivate_OnAnInactiveClient_MakesItActiveAgain() + { + var client = ClientWithContacts(); + client.Inactivate(); + + var result = client.Reactivate(); + + result.IsSuccess.Should().BeTrue(); + client.Status.Should().Be(ClientStatus.Active); + } + + [Fact] + public void Reactivate_KeepsTheIdentityAndTheContactsOfTheClient() + { + var client = ClientWithContacts(); + var id = client.Id; + var guardianIds = client.Guardians.Select(guardian => guardian.Id).ToArray(); + var referenceContactIds = client.ReferenceContacts.Select(contact => contact.Id).ToArray(); + client.Inactivate(); + + client.Reactivate(); + + client.Id.Should().Be(id); + client.Guardians.Select(guardian => guardian.Id).Should().Equal(guardianIds); + client.ReferenceContacts.Select(contact => contact.Id).Should().Equal(referenceContactIds); + } + + [Fact] + public void Reactivate_OnAnActiveClient_IsRefusedWithAlreadyActive() + { + var client = ClientWithContacts(); + + var result = client.Reactivate(); + + result.IsFailure.Should().BeTrue(); + result.Error.Should().Be(Client.AlreadyActive); + result.Error.Code.Should().Be("Client.AlreadyActive"); + client.Status.Should().Be(ClientStatus.Active); + } +} diff --git a/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs new file mode 100644 index 00000000..007c015a --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs @@ -0,0 +1,153 @@ +using Admin.SharedKernel; +using Microsoft.Extensions.Logging; +using ServicesService.Application.Abstractions; +using ServicesService.Application.Clients.DeactivateClient; + +namespace ServicesService.Tests.Clients.DeactivateClient; + +public class DeactivateClientCommandHandlerTests +{ + private static readonly DateTimeOffset Now = new(2026, 10, 7, 14, 0, 0, TimeSpan.Zero); + + private readonly IClientRepository _repository = Substitute.For(); + private readonly IAppointmentRepository _appointments = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly ILogger _logger = Substitute.For>(); + private readonly Client _client = ClientWithContacts(); + + public DeactivateClientCommandHandlerTests() + { + _repository.GetByIdAsync(_client.Id, Arg.Any()).Returns(Task.FromResult(_client)); + _repository.UpdateStatusAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + _appointments.ExistsNotCancelledStartingAfterAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(false)); + _unitOfWork.SaveChangesAsync(Arg.Any()).Returns(PersistenceResult.Success(1)); + } + + private DeactivateClientCommandHandler Handler(DateTimeOffset? utcNow = null) => + new(_repository, _appointments, _unitOfWork, new FixedTimeProvider(utcNow ?? Now), _logger); + + private static Client ClientWithContacts() + { + return Client.Create( + ClientTestData.Data( + ClientTestData.Name("Paula Rocha"), + email: ClientTestData.Email("paula@example.com"), + guardians: [ClientTestData.Guardian("Ana Souza"), ClientTestData.Guardian("Bia Souza")], + referenceContacts: [ClientTestData.ReferenceContact()]), + ClientTestData.Today).Value; + } + + private async Task AssertNothingWasPersisted() + { + await _repository.DidNotReceive().UpdateStatusAsync(Arg.Any(), Arg.Any()); + await _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + _repository.DidNotReceive().Add(Arg.Any()); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_WithNoUpcomingAppointment_InactivatesTheClientAndSavesItsStatus() + { + var result = await Handler().Handle(new DeactivateClientCommand(_client.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Id.Should().Be(_client.Id); + result.Value.Status.Should().Be("inactive"); + _client.Status.Should().Be(ClientStatus.Inactive); + await _repository.Received(1).UpdateStatusAsync(_client, Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + await _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + _repository.DidNotReceive().Add(Arg.Any()); + } + + [Fact] + public async Task Handle_KeepsTheDataAndEveryContactOfTheClient() + { + var guardianIds = _client.Guardians.Select(guardian => guardian.Id).ToArray(); + var referenceContactId = _client.ReferenceContacts.Single().Id; + + var result = await Handler().Handle(new DeactivateClientCommand(_client.Id), CancellationToken.None); + + var response = result.Value; + response.FullName.Should().Be("Paula Rocha"); + response.Email.Should().Be("paula@example.com"); + response.Guardians.Select(guardian => guardian.Id).Should().BeEquivalentTo(guardianIds); + response.ReferenceContacts.Should().ContainSingle().Which.Id.Should().Be(referenceContactId); + } + + [Fact] + public async Task Handle_AsksForAnAppointmentStartingAfterTheInstantOfTheOperation() + { + await Handler(Now).Handle(new DeactivateClientCommand(_client.Id), CancellationToken.None); + + await _appointments.Received(1).ExistsNotCancelledStartingAfterAsync(_client.Id, Now, Arg.Any()); + } + + [Fact] + public async Task Handle_WithAnUpcomingNotCancelledAppointment_ReturnsAConflictAndKeepsTheClientActive() + { + _appointments.ExistsNotCancelledStartingAfterAsync(_client.Id, Now, Arg.Any()) + .Returns(Task.FromResult(true)); + + var result = await Handler().Handle(new DeactivateClientCommand(_client.Id), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.Error.Type.Should().Be(ErrorType.Conflict); + result.Error.Code.Should().Be("Client.HasUpcomingAppointments"); + result.Error.Message.Should().Be( + "Não é possível desativar esta pessoa porque ela tem agendamentos futuros que não foram cancelados. Resolva esses agendamentos e tente novamente."); + _client.Status.Should().Be(ClientStatus.Active); + await AssertNothingWasPersisted(); + } + + [Fact] + public async Task Handle_WithAnIdTheRepositoryDoesNotKnow_ReturnsNotFoundWithoutReadingAppointments() + { + var unknownId = Guid.NewGuid(); + _repository.GetByIdAsync(unknownId, Arg.Any()).Returns(Task.FromResult(null)); + + var result = await Handler().Handle(new DeactivateClientCommand(unknownId), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.Error.Type.Should().Be(ErrorType.NotFound); + result.Error.Code.Should().Be("Client.NotFound"); + result.Error.Message.Should().Be("A pessoa não foi encontrada."); + await _appointments.DidNotReceive().ExistsNotCancelledStartingAfterAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + await AssertNothingWasPersisted(); + } + + [Fact] + public async Task Handle_WhenTheClientIsAlreadyInactive_ReturnsItsCurrentStateWithoutReadingAppointmentsOrSaving() + { + _client.Inactivate(); + + var result = await Handler().Handle(new DeactivateClientCommand(_client.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Id.Should().Be(_client.Id); + result.Value.Status.Should().Be("inactive"); + result.Value.Guardians.Should().HaveCount(2); + await _appointments.DidNotReceive().ExistsNotCancelledStartingAfterAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()); + await AssertNothingWasPersisted(); + } + + [Fact] + public async Task Handle_WhenTheDatabaseRejectsTheSave_ReturnsAGenericConflict() + { + _unitOfWork.SaveChangesAsync(Arg.Any()).Returns( + PersistenceResult.Failure(new PersistenceError(PersistenceErrorKind.UniqueConstraintViolation, "IX_Clients_TenantId_Email"))); + + var result = await Handler().Handle(new DeactivateClientCommand(_client.Id), CancellationToken.None); + + result.Error.Type.Should().Be(ErrorType.Conflict); + result.Error.Code.Should().Be("Client.SaveFailed"); + result.Error.FieldErrors.Should().BeNull(); + } +} diff --git a/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandValidatorTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandValidatorTests.cs new file mode 100644 index 00000000..f7e85053 --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandValidatorTests.cs @@ -0,0 +1,27 @@ +using ServicesService.Application.Clients.DeactivateClient; + +namespace ServicesService.Tests.Clients.DeactivateClient; + +public class DeactivateClientCommandValidatorTests +{ + private readonly DeactivateClientCommandValidator _validator = new(); + + [Fact] + public async Task Validate_WithAnId_Passes() + { + var result = await _validator.ValidateAsync(new DeactivateClientCommand(Guid.NewGuid()), TestContext.Current.CancellationToken); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public async Task Validate_WithAnEmptyId_RejectsItWithTheClientIdCode() + { + var result = await _validator.ValidateAsync(new DeactivateClientCommand(Guid.Empty), TestContext.Current.CancellationToken); + + var error = result.Errors.Should().ContainSingle().Subject; + error.PropertyName.Should().Be(nameof(DeactivateClientCommand.ClientId)); + error.ErrorCode.Should().Be("Client.IdRequired"); + error.ErrorMessage.Should().Be("O id da pessoa é obrigatório."); + } +} diff --git a/backend/services/services-service/ServicesService.Tests/Clients/GetClientById/GetClientByIdQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/GetClientById/GetClientByIdQueryHandlerTests.cs index 3cee9d8f..f60003d9 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/GetClientById/GetClientByIdQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/GetClientById/GetClientByIdQueryHandlerTests.cs @@ -44,7 +44,7 @@ private static Client CompleteClient() private static void MakeInactive(Client client) { - typeof(Client).GetProperty(nameof(Client.Status))!.SetValue(client, ClientStatus.Inactive); + client.Inactivate(); } [Fact] diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ReactivateClient/ReactivateClientCommandHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ReactivateClient/ReactivateClientCommandHandlerTests.cs new file mode 100644 index 00000000..6962e20c --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/ReactivateClient/ReactivateClientCommandHandlerTests.cs @@ -0,0 +1,170 @@ +using Admin.SharedKernel; +using Microsoft.Extensions.Logging; +using ServicesService.Application.Abstractions; +using ServicesService.Application.Clients.ReactivateClient; + +namespace ServicesService.Tests.Clients.ReactivateClient; + +public class ReactivateClientCommandHandlerTests +{ + private readonly IClientRepository _repository = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly ILogger _logger = Substitute.For>(); + private readonly Client _client = InactiveClientWithContacts(); + + public ReactivateClientCommandHandlerTests() + { + _repository.GetByIdAsync(_client.Id, Arg.Any()).Returns(Task.FromResult(_client)); + _repository.UpdateStatusAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + _repository.FindActiveByEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); + _unitOfWork.SaveChangesAsync(Arg.Any()).Returns(PersistenceResult.Success(1)); + } + + private ReactivateClientCommandHandler Handler() => new(_repository, _unitOfWork, _logger); + + private static Client InactiveClientWithContacts(EmailAddress? email = null) + { + var client = Client.Create( + ClientTestData.Data( + ClientTestData.Name("Paula Rocha"), + email: email ?? ClientTestData.Email("paula@example.com"), + guardians: [ClientTestData.Guardian("Ana Souza"), ClientTestData.Guardian("Bia Souza")], + referenceContacts: [ClientTestData.ReferenceContact()]), + ClientTestData.Today).Value; + client.Inactivate(); + return client; + } + + private async Task AssertNothingWasPersisted() + { + await _repository.DidNotReceive().UpdateStatusAsync(Arg.Any(), Arg.Any()); + await _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + _repository.DidNotReceive().Add(Arg.Any()); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_WithAnEmailNoOtherActiveClientUses_ReactivatesTheClientAndSavesItsStatus() + { + var result = await Handler().Handle(new ReactivateClientCommand(_client.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Id.Should().Be(_client.Id); + result.Value.Status.Should().Be("active"); + _client.Status.Should().Be(ClientStatus.Active); + await _repository.Received(1).UpdateStatusAsync(_client, Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); + await _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + _repository.DidNotReceive().Add(Arg.Any()); + } + + [Fact] + public async Task Handle_ChecksTheEmailOfTheClientExcludingTheClientItself() + { + await Handler().Handle(new ReactivateClientCommand(_client.Id), CancellationToken.None); + + await _repository.Received(1).FindActiveByEmailAsync( + ClientTestData.Email("paula@example.com"), + _client.Id, + Arg.Any()); + } + + [Fact] + public async Task Handle_WithoutAnEmail_ReactivatesWithoutCheckingUniqueness() + { + var client = Client.Create(ClientTestData.Data(), ClientTestData.Today).Value; + client.Inactivate(); + _repository.GetByIdAsync(client.Id, Arg.Any()).Returns(Task.FromResult(client)); + + var result = await Handler().Handle(new ReactivateClientCommand(client.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Status.Should().Be("active"); + await _repository.DidNotReceive().FindActiveByEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task Handle_KeepsTheDataAndEveryContactOfTheClient() + { + var guardianIds = _client.Guardians.Select(guardian => guardian.Id).ToArray(); + var referenceContactId = _client.ReferenceContacts.Single().Id; + + var result = await Handler().Handle(new ReactivateClientCommand(_client.Id), CancellationToken.None); + + var response = result.Value; + response.FullName.Should().Be("Paula Rocha"); + response.Email.Should().Be("paula@example.com"); + response.Guardians.Select(guardian => guardian.Id).Should().BeEquivalentTo(guardianIds); + response.ReferenceContacts.Should().ContainSingle().Which.Id.Should().Be(referenceContactId); + } + + [Fact] + public async Task Handle_WithTheEmailOfAnotherActiveClient_ReturnsAConflictPointingAtItAndKeepsTheClientInactive() + { + var other = ClientTestData.ExistingClient(); + _repository.FindActiveByEmailAsync(ClientTestData.Email("paula@example.com"), _client.Id, Arg.Any()) + .Returns(Task.FromResult(other)); + + var result = await Handler().Handle(new ReactivateClientCommand(_client.Id), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.Error.Type.Should().Be(ErrorType.Conflict); + result.Error.Code.Should().Be("Client.DuplicateEmail"); + result.Error.FieldErrors!.Keys.Should().Equal("Email"); + var fieldError = result.Error.FieldErrors["Email"].Should().ContainSingle().Subject; + fieldError.Message.Should().Be( + "Não é possível reativar esta pessoa porque outra pessoa ativa já usa este e-mail. Corrija o e-mail deste cadastro e tente novamente."); + fieldError.Meta.Should().BeEquivalentTo(new Dictionary + { + ["clientId"] = other.Id.ToString(), + ["clientName"] = "Paula Rocha", + }); + _client.Status.Should().Be(ClientStatus.Inactive); + await AssertNothingWasPersisted(); + } + + [Fact] + public async Task Handle_WithAnIdTheRepositoryDoesNotKnow_ReturnsNotFound() + { + var unknownId = Guid.NewGuid(); + _repository.GetByIdAsync(unknownId, Arg.Any()).Returns(Task.FromResult(null)); + + var result = await Handler().Handle(new ReactivateClientCommand(unknownId), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.Error.Type.Should().Be(ErrorType.NotFound); + result.Error.Code.Should().Be("Client.NotFound"); + result.Error.Message.Should().Be("A pessoa não foi encontrada."); + await _repository.DidNotReceive().FindActiveByEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await AssertNothingWasPersisted(); + } + + [Fact] + public async Task Handle_WhenTheClientIsAlreadyActive_ReturnsItsCurrentStateWithoutCheckingOrSaving() + { + _client.Reactivate(); + + var result = await Handler().Handle(new ReactivateClientCommand(_client.Id), CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + result.Value.Id.Should().Be(_client.Id); + result.Value.Status.Should().Be("active"); + result.Value.Guardians.Should().HaveCount(2); + await _repository.DidNotReceive().FindActiveByEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await AssertNothingWasPersisted(); + } + + [Fact] + public async Task Handle_WhenTheDatabaseRejectsTheSave_ReturnsAGenericConflict() + { + _unitOfWork.SaveChangesAsync(Arg.Any()).Returns( + PersistenceResult.Failure(new PersistenceError(PersistenceErrorKind.UniqueConstraintViolation, "IX_Clients_TenantId_Email"))); + + var result = await Handler().Handle(new ReactivateClientCommand(_client.Id), CancellationToken.None); + + result.Error.Type.Should().Be(ErrorType.Conflict); + result.Error.Code.Should().Be("Client.SaveFailed"); + result.Error.FieldErrors.Should().BeNull(); + } +} diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ReactivateClient/ReactivateClientCommandValidatorTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ReactivateClient/ReactivateClientCommandValidatorTests.cs new file mode 100644 index 00000000..45e33fde --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/ReactivateClient/ReactivateClientCommandValidatorTests.cs @@ -0,0 +1,27 @@ +using ServicesService.Application.Clients.ReactivateClient; + +namespace ServicesService.Tests.Clients.ReactivateClient; + +public class ReactivateClientCommandValidatorTests +{ + private readonly ReactivateClientCommandValidator _validator = new(); + + [Fact] + public async Task Validate_WithAnId_Passes() + { + var result = await _validator.ValidateAsync(new ReactivateClientCommand(Guid.NewGuid()), TestContext.Current.CancellationToken); + + result.IsValid.Should().BeTrue(); + } + + [Fact] + public async Task Validate_WithAnEmptyId_RejectsItWithTheClientIdCode() + { + var result = await _validator.ValidateAsync(new ReactivateClientCommand(Guid.Empty), TestContext.Current.CancellationToken); + + var error = result.Errors.Should().ContainSingle().Subject; + error.PropertyName.Should().Be(nameof(ReactivateClientCommand.ClientId)); + error.ErrorCode.Should().Be("Client.IdRequired"); + error.ErrorMessage.Should().Be("O id da pessoa é obrigatório."); + } +} diff --git a/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandHandlerTests.cs index 6f96a998..34ab8f2a 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandHandlerTests.cs @@ -79,7 +79,7 @@ private static PersistenceResult UniqueViolation(string? constraint) => private static void MakeInactive(Client client) { - typeof(Client).GetProperty(nameof(Client.Status))!.SetValue(client, ClientStatus.Inactive); + client.Inactivate(); } [Fact] diff --git a/docs/adr/0062-clients-deactivate-and-reactivate.md b/docs/adr/0062-clients-deactivate-and-reactivate.md new file mode 100644 index 00000000..f2da7e7c --- /dev/null +++ b/docs/adr/0062-clients-deactivate-and-reactivate.md @@ -0,0 +1,94 @@ +# ADR 0062 — Clients: deactivate and reactivate + +Status: accepted (2026-10); extends [ADR 0044](0044-clients-aggregate-uniqueness-and-conflict-contract.md) (the +situation) and [ADR 0060](0060-clients-read-by-id-contract.md) (the response) + +## Context + +[ADR 0044](0044-clients-aggregate-uniqueness-and-conflict-contract.md) gave `Client` a `ClientStatus { Active, +Inactive }` and no transition. #157 (deactivate) and #158 (reactivate) add the two transitions as endpoints. +Three things were open: the deactivation rule needs appointments, and the appointments module (#153) does not exist +yet; what a repeated action answers; and how a status change is written without disturbing the contacts that +[ADR 0056](0056-clients-edit-replaces-contact-composition.md) makes the edit replace. + +## Decision + +**Endpoints.** `POST /api/v1/clients/{id}/deactivate` and `POST /api/v1/clients/{id}/reactivate`, no body. Both answer +`200` with the same `ClientResponse` as `PUT` (ADR 0060), `status` saying the new situation. An unknown id, an id of +another tenant and an id of a deleted client answer the same `404 Client.NotFound`: the two query filters do it, the +handler adds no check. An empty id is `400 Client.IdRequired`. + +**Transitions.** `Client.Inactivate()` and `Client.Reactivate()` change the status and nothing else: the id, the +person's data and every contact stay. Each refuses the transition it was already in with a named error +(`Client.AlreadyInactive`, `Client.AlreadyActive`). + +**Repeating the action is not an error.** Deactivating an inactive person, or reactivating an active one, answers `200` +with the current state; the handler returns before reading appointments or e-mails and before any write, so nothing +changes, not even `UpdatedAt`. This was a product choice over a `409` with the named errors; the domain still refuses +the repeated transition, the handler just never reaches it. + +**Persisting a status alone.** `IClientRepository.UpdateStatusAsync` attaches the loaded client and marks the `Status` +column as modified. `UpdateAsync` cannot be used: it replaces the contact composition, which would soft-delete and +re-create every contact on a status change, and `Entry(client).State = Modified` would write every column from the +snapshot the handler loaded, overwriting an edit saved meanwhile. `ClientPersistenceTests` prove both, including the +second one against the all-columns write. + +**Deactivation is blocked by upcoming appointments (#157).** The handler asks a port, +`IAppointmentRepository.ExistsNotCancelledStartingAfterAsync(clientId, instant)`, with the operation's instant +(`TimeProvider`, UTC, [ADR 0045](0045-backend-works-in-utc.md)). The rule is **strict**: an appointment blocks when its +start is after the instant. An appointment that starts exactly at the instant, one already in progress (it started in +the past) and a cancelled one never block. A blocked deactivation answers `409 Client.HasUpcomingAppointments`, in +pt-BR telling the user to resolve the appointments, and the person stays active. + +**Until #153 exists the port has a provisional adapter**, `PendingAppointmentRepository`, which answers "none". That is +true today, because nothing can create an appointment, and it lets the rule and its endpoint ship now. #153 replaces the +registration in `Infrastructure/DependencyInjection.cs` with the real repository in the same change that creates the +first appointment, and deletes the adapter. What this change's unit tests prove is the handler's half: it passes the +operation's instant, it honours the answer, and a blocked or repeated call writes nothing. What they cannot prove is +the query's half (the strict start comparison and the cancelled status over real rows); those two tests belong to +#153's persistence tests. + +**Reactivation is blocked by an active person's e-mail (#158).** Before the transition the handler runs +`FindActiveByEmailAsync(email, excludeClientId: self)`. A conflict answers `409 Client.DuplicateEmail`, keyed `email`, +with `clientId` and `clientName` in `meta`, the contract of ADR 0044 and +[ADR 0051](0051-camelcase-error-keys-on-the-wire.md), with a message of its own that says to fix this person's e-mail. +Another **inactive** person with the same e-mail does not block. A person with no e-mail reactivates. The e-mail is +corrected through the existing edit, which accepts an inactive person and checks the e-mail only once the person is +active. + +**Concurrency.** No migration. The e-mail rule is already the partial unique index +`IX_Clients_TenantId_Email ... WHERE "Status" = 'Active' AND "DeletedAt" IS NULL`, so setting a row back to `Active` +is a write the database rejects when another active row has the e-mail. Per +[ADR 0048](0048-database-failures-are-generic-to-the-user.md) the lost race answers `409 Client.SaveFailed` and the +retry gets the specific `Client.DuplicateEmail`. Two parallel deactivations of the same person both answer `200`. + +## Consequences + +- Wire: two new operations on the clients resource; `services-api.d.ts` is regenerated in this change. +- The handler tests of the edit and of the read that made a client inactive by reflection now call `Inactivate()`. +- Accepted limit: between the appointment read and the save, #153 could create an appointment for the person. Nothing + in this service's database can forbid it. #153 already has to re-check that the person is active when it saves, which + closes the window from its side. +- The interface actions (**Desativar**, **Reativar**, the confirmation and the messages) are the frontend's and are not + part of this change. + +Verified by hand on a disposable PostgreSQL 18 with the real services and a real login, 51 checks: deactivate and +reactivate with contacts, contact rows untouched (none inserted, none soft-deleted, none stamped), repetition writing +nothing, unknown, other-tenant and deleted ids, the e-mail conflict and its `meta`, the fix-then-reactivate path, +inactive-versus-inactive, 12 parallel reactivation races (always one `200` and one `Client.SaveFailed`, never two active +rows with the e-mail), 6 parallel deactivations, and the OpenAPI document. + +## Considered and rejected + +- **A `409` on a repeated action.** Follows the letter of "refuse an invalid transition", but a second click or a second + tab becomes an error for a state the user wanted. +- **`UpdateAsync` for the status.** It replaces the contacts (ADR 0056). +- **`Entry(client).State = Modified`.** Writes every column from a stale snapshot. +- **A minimal `Appointment` entity in this change.** It would decide part of #153's model (situations, names, keys) so the + check could run against a table. +- **Delivering only #158 and waiting for #153 to do #157.** The issue sequences it that way, but it left the endpoint, + the transition and the rule's handler half undone for no gain; the port makes the later swap one registration. +- **`>=` instead of `>` for the start.** Also defensible; the issue says "início futuro" and an appointment that starts at + the instant of the click has started. A one-line change in the adapter #153 writes. +- **A code of its own for the reactivation e-mail conflict.** `Client.DuplicateEmail` already carries the field and the + `meta` the interface uses to open the other person; a second code would make the interface handle two for one cause. diff --git a/docs/adr/README.md b/docs/adr/README.md index 82d11890..14707e10 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -10,7 +10,7 @@ superseded is historical evidence, not current implementation guidance. | Service and data topology | 0001, 0002, 0003, 0024, 0029 | | Backend CQRS, validation, Result flow | 0005, 0006, 0007, 0009, 0012 as narrowed by 0049, 0014, 0018, 0048, 0049 as narrowed by 0061, 0051, 0057 | | Tenant assignment and persistence | 0006, 0008, 0009, 0017, 0019, 0024, 0028, 0046 | -| Aggregates, value objects, factories, DTOs, uniqueness and field conflicts | 0044 as narrowed by 0051 and 0058, 0046, 0048, 0049, 0055, 0056, 0058, 0059, 0060 | +| Aggregates, value objects, factories, DTOs, uniqueness and field conflicts | 0044 as narrowed by 0051 and 0058, 0046, 0048, 0049, 0055, 0056, 0058, 0059, 0060, 0062 | | Dates, times and time zones | 0045 | | Testing and runtime smokes | 0004, 0015 as narrowed by 0019/0026, 0026, 0050 | | Authentication UX and AI delegation | 0020, 0022 | @@ -84,4 +84,5 @@ instruction files reinstated · 0042 widgets layer and layer-boundaries lint rul 0058 closed-set members are plain enums bound by name · 0059 aggregate data records and handler-owned domain calls · 0060 clients read by id: one response, no age on the wire, contacts ordered by name · -0061 validators write their own rules. +0061 validators write their own rules · +0062 clients deactivate and reactivate. From a1eab17189eea5fab41caffa07bd8d8ef86dc3f6 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Wed, 7 Oct 2026 20:36:32 -0300 Subject: [PATCH 2/4] refactor(services): simplificar a porta de agendamentos futuros (#157, backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IAppointmentRepository.ExistsNotCancelledStartingAfterAsync(clientId, instant) vira HasUpcomingAppointmentsAsync(clientId): a porta deixa de carregar um DateTimeOffset e o adaptador decide o que é "futuro" com o TimeProvider em UTC. O handler de desativar deixa de injetar o TimeProvider. A regra não muda: bloqueia o agendamento não cancelado cujo início do atendimento é estritamente depois do instante da consulta. O ADR 0062 registra o novo desenho e a versão com parâmetro como rejeitada. Co-Authored-By: Claude Sonnet 5.5 --- .../Abstractions/IAppointmentRepository.cs | 2 +- .../DeactivateClientCommandHandler.cs | 10 ++----- .../PendingAppointmentRepository.cs | 5 +--- .../DeactivateClientCommandHandlerTests.cs | 27 +++++++------------ .../0062-clients-deactivate-and-reactivate.md | 25 ++++++++++------- 5 files changed, 28 insertions(+), 41 deletions(-) diff --git a/backend/services/services-service/ServicesService.Application/Abstractions/IAppointmentRepository.cs b/backend/services/services-service/ServicesService.Application/Abstractions/IAppointmentRepository.cs index d25a8d3d..9e6f1877 100644 --- a/backend/services/services-service/ServicesService.Application/Abstractions/IAppointmentRepository.cs +++ b/backend/services/services-service/ServicesService.Application/Abstractions/IAppointmentRepository.cs @@ -2,5 +2,5 @@ namespace ServicesService.Application.Abstractions; public interface IAppointmentRepository { - Task ExistsNotCancelledStartingAfterAsync(Guid clientId, DateTimeOffset instant, CancellationToken cancellationToken); + Task HasUpcomingAppointmentsAsync(Guid clientId, CancellationToken cancellationToken); } diff --git a/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandHandler.cs index 83468e47..872c98d6 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandHandler.cs @@ -8,7 +8,6 @@ public sealed class DeactivateClientCommandHandler( IClientRepository clientRepository, IAppointmentRepository appointmentRepository, IUnitOfWork unitOfWork, - TimeProvider timeProvider, ILogger logger) : ICommandHandler { public async Task> Handle(DeactivateClientCommand command, CancellationToken cancellationToken) @@ -56,13 +55,8 @@ public async Task> Handle(DeactivateClientCommand command private async Task FindUpcomingAppointmentConflictAsync(Client client, CancellationToken cancellationToken) { - var now = timeProvider.GetUtcNow(); - - var hasUpcomingAppointment = await appointmentRepository.ExistsNotCancelledStartingAfterAsync( - client.Id, - now, - cancellationToken); - if (!hasUpcomingAppointment) + var hasUpcomingAppointments = await appointmentRepository.HasUpcomingAppointmentsAsync(client.Id, cancellationToken); + if (!hasUpcomingAppointments) { return null; } diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/PendingAppointmentRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/PendingAppointmentRepository.cs index 9a1496fa..ab087f4b 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/PendingAppointmentRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/PendingAppointmentRepository.cs @@ -4,10 +4,7 @@ namespace ServicesService.Infrastructure.Repositories; public class PendingAppointmentRepository : IAppointmentRepository { - public Task ExistsNotCancelledStartingAfterAsync( - Guid clientId, - DateTimeOffset instant, - CancellationToken cancellationToken) + public Task HasUpcomingAppointmentsAsync(Guid clientId, CancellationToken cancellationToken) { return Task.FromResult(false); } diff --git a/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs index 007c015a..46a1bfa8 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs @@ -7,8 +7,6 @@ namespace ServicesService.Tests.Clients.DeactivateClient; public class DeactivateClientCommandHandlerTests { - private static readonly DateTimeOffset Now = new(2026, 10, 7, 14, 0, 0, TimeSpan.Zero); - private readonly IClientRepository _repository = Substitute.For(); private readonly IAppointmentRepository _appointments = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); @@ -19,13 +17,12 @@ public DeactivateClientCommandHandlerTests() { _repository.GetByIdAsync(_client.Id, Arg.Any()).Returns(Task.FromResult(_client)); _repository.UpdateStatusAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); - _appointments.ExistsNotCancelledStartingAfterAsync(Arg.Any(), Arg.Any(), Arg.Any()) + _appointments.HasUpcomingAppointmentsAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(false)); _unitOfWork.SaveChangesAsync(Arg.Any()).Returns(PersistenceResult.Success(1)); } - private DeactivateClientCommandHandler Handler(DateTimeOffset? utcNow = null) => - new(_repository, _appointments, _unitOfWork, new FixedTimeProvider(utcNow ?? Now), _logger); + private DeactivateClientCommandHandler Handler() => new(_repository, _appointments, _unitOfWork, _logger); private static Client ClientWithContacts() { @@ -77,17 +74,17 @@ public async Task Handle_KeepsTheDataAndEveryContactOfTheClient() } [Fact] - public async Task Handle_AsksForAnAppointmentStartingAfterTheInstantOfTheOperation() + public async Task Handle_AsksOnceForTheUpcomingAppointmentsOfTheClient() { - await Handler(Now).Handle(new DeactivateClientCommand(_client.Id), CancellationToken.None); + await Handler().Handle(new DeactivateClientCommand(_client.Id), CancellationToken.None); - await _appointments.Received(1).ExistsNotCancelledStartingAfterAsync(_client.Id, Now, Arg.Any()); + await _appointments.Received(1).HasUpcomingAppointmentsAsync(_client.Id, Arg.Any()); } [Fact] - public async Task Handle_WithAnUpcomingNotCancelledAppointment_ReturnsAConflictAndKeepsTheClientActive() + public async Task Handle_WithUpcomingAppointments_ReturnsAConflictAndKeepsTheClientActive() { - _appointments.ExistsNotCancelledStartingAfterAsync(_client.Id, Now, Arg.Any()) + _appointments.HasUpcomingAppointmentsAsync(_client.Id, Arg.Any()) .Returns(Task.FromResult(true)); var result = await Handler().Handle(new DeactivateClientCommand(_client.Id), CancellationToken.None); @@ -113,10 +110,7 @@ public async Task Handle_WithAnIdTheRepositoryDoesNotKnow_ReturnsNotFoundWithout result.Error.Type.Should().Be(ErrorType.NotFound); result.Error.Code.Should().Be("Client.NotFound"); result.Error.Message.Should().Be("A pessoa não foi encontrada."); - await _appointments.DidNotReceive().ExistsNotCancelledStartingAfterAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()); + await _appointments.DidNotReceive().HasUpcomingAppointmentsAsync(Arg.Any(), Arg.Any()); await AssertNothingWasPersisted(); } @@ -131,10 +125,7 @@ public async Task Handle_WhenTheClientIsAlreadyInactive_ReturnsItsCurrentStateWi result.Value.Id.Should().Be(_client.Id); result.Value.Status.Should().Be("inactive"); result.Value.Guardians.Should().HaveCount(2); - await _appointments.DidNotReceive().ExistsNotCancelledStartingAfterAsync( - Arg.Any(), - Arg.Any(), - Arg.Any()); + await _appointments.DidNotReceive().HasUpcomingAppointmentsAsync(Arg.Any(), Arg.Any()); await AssertNothingWasPersisted(); } diff --git a/docs/adr/0062-clients-deactivate-and-reactivate.md b/docs/adr/0062-clients-deactivate-and-reactivate.md index f2da7e7c..8da55306 100644 --- a/docs/adr/0062-clients-deactivate-and-reactivate.md +++ b/docs/adr/0062-clients-deactivate-and-reactivate.md @@ -34,19 +34,21 @@ snapshot the handler loaded, overwriting an edit saved meanwhile. `ClientPersist second one against the all-columns write. **Deactivation is blocked by upcoming appointments (#157).** The handler asks a port, -`IAppointmentRepository.ExistsNotCancelledStartingAfterAsync(clientId, instant)`, with the operation's instant -(`TimeProvider`, UTC, [ADR 0045](0045-backend-works-in-utc.md)). The rule is **strict**: an appointment blocks when its -start is after the instant. An appointment that starts exactly at the instant, one already in progress (it started in -the past) and a cancelled one never block. A blocked deactivation answers `409 Client.HasUpcomingAppointments`, in -pt-BR telling the user to resolve the appointments, and the person stays active. +`IAppointmentRepository.HasUpcomingAppointmentsAsync(clientId)`, and the adapter decides what "upcoming" means with +`TimeProvider` in UTC ([ADR 0045](0045-backend-works-in-utc.md)), so the port carries no instant. The rule is +**strict**: an appointment is upcoming when its start (the start of the service, not of the preparation) is after the +instant the query runs, and it is not cancelled. An appointment that starts exactly at that instant, one already in +progress (it started in the past) and a cancelled one never block. A blocked deactivation answers +`409 Client.HasUpcomingAppointments`, in pt-BR telling the user to resolve the appointments, and the person stays +active. **Until #153 exists the port has a provisional adapter**, `PendingAppointmentRepository`, which answers "none". That is true today, because nothing can create an appointment, and it lets the rule and its endpoint ship now. #153 replaces the -registration in `Infrastructure/DependencyInjection.cs` with the real repository in the same change that creates the -first appointment, and deletes the adapter. What this change's unit tests prove is the handler's half: it passes the -operation's instant, it honours the answer, and a blocked or repeated call writes nothing. What they cannot prove is -the query's half (the strict start comparison and the cancelled status over real rows); those two tests belong to -#153's persistence tests. +registration in `Infrastructure/DependencyInjection.cs` with the real repository, which reads the clock and applies the +rule above, in the same change that creates the first appointment, and deletes the adapter. What this change's unit +tests prove is the handler's half: it asks once, it honours the answer, and a blocked or repeated call writes nothing. +What they cannot prove is the query's half (the strict start comparison and the cancelled status over real rows, with a +fixed clock); those tests belong to #153's persistence tests. **Reactivation is blocked by an active person's e-mail (#158).** Before the transition the handler runs `FindActiveByEmailAsync(email, excludeClientId: self)`. A conflict answers `409 Client.DuplicateEmail`, keyed `email`, @@ -90,5 +92,8 @@ rows with the e-mail), 6 parallel deactivations, and the OpenAPI document. the transition and the rule's handler half undone for no gain; the port makes the later swap one registration. - **`>=` instead of `>` for the start.** Also defensible; the issue says "início futuro" and an appointment that starts at the instant of the click has started. A one-line change in the adapter #153 writes. +- **An instant parameter on the port** (`ExistsNotCancelledStartingAfterAsync(clientId, instant)`, the first version of + this change). It kept the clock in the handler, but put a `DateTimeOffset` and a three-condition name on a question + the handler only needs answered as yes or no; changed after review. - **A code of its own for the reactivation e-mail conflict.** `Client.DuplicateEmail` already carries the field and the `meta` the interface uses to open the other person; a second code would make the interface handle two for one cause. From 3ad792a576fd548e9327446b6e1728b5f5d1279a Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Wed, 7 Oct 2026 21:00:36 -0300 Subject: [PATCH 3/4] =?UTF-8?q?refactor(services):=20log=20no=20UnitOfWork?= =?UTF-8?q?,=20UpdateAsync=20por=20id=20e=20CPF=20na=20reativa=C3=A7=C3=A3?= =?UTF-8?q?o=20(#157,=20#158,=20backend)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atende a revisão do PR. - UnitOfWork passa a logar, uma vez, todo save rejeitado pelo banco (Warning, tipo e constraint). Criar, editar, desativar e reativar cliente perdem o ILogger e só devolvem Client.SaveFailed. - IClientRepository.UpdateAsync compara os contatos por id e grava só a diferença. Antes lançava exceção para um cliente com contatos que não mudaram, então desativar e reativar usam o mesmo UpdateAsync do PUT e UpdateStatusAsync deixa de existir. O PUT continua substituindo tudo, porque cria contatos com ids novos. - Reativar também checa o CPF contra outra pessoa (409 Client.DuplicateCpf, antes do e-mail), como criar e editar. O índice já impede o conflito, então é uma checagem defensiva. - ADR 0062 reescrito nessas partes, ADR 0048 e ARCHITECTURE §4 e as referências da skill de slice acompanham. Co-Authored-By: Claude Sonnet 5.5 --- .../references/persistence.md | 4 +- .../references/use-case.md | 4 +- backend/docs/ARCHITECTURE.md | 6 +- .../Abstractions/IClientRepository.cs | 3 - .../CreateClientCommandHandler.cs | 9 +- .../DeactivateClientCommandHandler.cs | 11 +- .../ReactivateClientCommandHandler.cs | 52 +++++--- .../UpdateClientCommandHandler.cs | 9 +- .../Persistence/UnitOfWork.cs | 10 +- .../Repositories/ClientRepository.cs | 22 ++-- .../ClientPersistenceTests.cs | 50 ++------ .../UnitOfWorkTests.cs | 116 ++++++++++++++++++ .../CreateClientCommandHandlerTests.cs | 4 +- .../DeactivateClientCommandHandlerTests.cs | 10 +- .../ReactivateClientCommandHandlerTests.cs | 68 ++++++++-- .../UpdateClientCommandHandlerTests.cs | 4 +- ...tabase-failures-are-generic-to-the-user.md | 4 + .../0062-clients-deactivate-and-reactivate.md | 59 ++++++--- 18 files changed, 303 insertions(+), 142 deletions(-) create mode 100644 backend/services/services-service/ServicesService.PersistenceTests/UnitOfWorkTests.cs diff --git a/.claude/skills/agenza-backend-slice/references/persistence.md b/.claude/skills/agenza-backend-slice/references/persistence.md index e1902c86..cf9efc3d 100644 --- a/.claude/skills/agenza-backend-slice/references/persistence.md +++ b/.claude/skills/agenza-backend-slice/references/persistence.md @@ -35,8 +35,8 @@ that differs only by tracking (ARCHITECTURE, rules of thumb). - `public class Repository : RepositoryBase<>, IRepository`, constructed from the service's `DbContext`; registered in Infrastructure's `DependencyInjection.cs`. - Use the base helpers (`FindAsync`, `ListAsync`, `ListPagedAsync`, `AnyAsync`) and `Set` for the - rest. An update adapter can query only the persisted children it must replace, then explicitly - stage their removal and the new composition. `Include` only the root's own children. + rest. An update adapter can query the persisted children and stage only the difference by id: remove + the ones the aggregate no longer holds, add the ones the database does not have yet. `Include` only the root's own children. - Order every list and page explicitly, on a key that is unique or ends in one — otherwise rows repeat or vanish between pages. - A value-object column is compared as a whole (`c.Cpf == cpf`); text search on it goes through diff --git a/.claude/skills/agenza-backend-slice/references/use-case.md b/.claude/skills/agenza-backend-slice/references/use-case.md index d8b209d6..196b0a10 100644 --- a/.claude/skills/agenza-backend-slice/references/use-case.md +++ b/.claude/skills/agenza-backend-slice/references/use-case.md @@ -79,8 +79,8 @@ validator already ran before the handler): `FindConflictAsync` returning `Error?`; a conflict a form can show is keyed by field, with `meta`; one per answer. 5. Behaviour, `Add`, or `UpdateAsync`. -6. `SaveChangesAsync`; a failure logs kind and constraint at `Warning` and returns - `.SaveFailed`. +6. `SaveChangesAsync`; a failure returns `.SaveFailed`. The unit of work has already logged its + kind and constraint at `Warning`, so the handler does not. 7. Return `Response.From(…)`. An edit runs `ApplyTo` (or `.Update(command.ToData(), today)`) at step 3, before the pre-checks, because they compare the values it just assigned diff --git a/backend/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 2cc1b99a..b522caf7 100644 --- a/backend/docs/ARCHITECTURE.md +++ b/backend/docs/ARCHITECTURE.md @@ -265,9 +265,9 @@ meta)`, with `meta` carrying machine context such as the existing record's id an per answer, in a fixed order. Cheap rejections run before work with side effects (consuming a sequence, calling out). -**Unit of work.** A failed save is logged at `Warning` with its kind and constraint, and answered as -`Error.Conflict(".SaveFailed", "Não foi possível salvar … Tente novamente.")` — no field, no -claim about which rule broke. Application never knows a constraint or index name; the retry goes +**Unit of work.** The unit of work logs a failed save at `Warning` with its kind and constraint, once, for every +handler; the handler only answers `Error.Conflict(".SaveFailed", "Não foi possível salvar … Tente +novamente.")` — no field, no claim about which rule broke, no logger. Application never knows a constraint or index name; the retry goes through the pre-checks and gets the specific answer ([0048](../../docs/adr/0048-database-failures-are-generic-to-the-user.md)). diff --git a/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs b/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs index 3f1ce3c0..bd89bbb6 100644 --- a/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs +++ b/backend/services/services-service/ServicesService.Application/Abstractions/IClientRepository.cs @@ -6,9 +6,6 @@ public interface IClientRepository Task UpdateAsync(Client client, CancellationToken cancellationToken); - // Writes the status alone: the contacts and the other columns stay as they are, unlike UpdateAsync. - Task UpdateStatusAsync(Client client, CancellationToken cancellationToken); - // excludeClientId ignores the client being edited, so saving a client without changing its CPF isn't a self-conflict. Task FindByCpfAsync(CpfNumber cpf, Guid? excludeClientId, CancellationToken cancellationToken); diff --git a/backend/services/services-service/ServicesService.Application/Clients/CreateClient/CreateClientCommandHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/CreateClient/CreateClientCommandHandler.cs index b997290e..6cecc831 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/CreateClient/CreateClientCommandHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/CreateClient/CreateClientCommandHandler.cs @@ -1,5 +1,4 @@ using Admin.SharedKernel; -using Microsoft.Extensions.Logging; using ServicesService.Application.Abstractions; namespace ServicesService.Application.Clients.CreateClient; @@ -7,8 +6,7 @@ namespace ServicesService.Application.Clients.CreateClient; public sealed class CreateClientCommandHandler( IClientRepository clientRepository, IUnitOfWork unitOfWork, - TimeProvider timeProvider, - ILogger logger) : ICommandHandler + TimeProvider timeProvider) : ICommandHandler { public async Task> Handle(CreateClientCommand command, CancellationToken cancellationToken) { @@ -40,11 +38,6 @@ public async Task> Handle(CreateClientCommand command, Ca var saveResult = await unitOfWork.SaveChangesAsync(cancellationToken); if (saveResult.IsFailure) { - logger.LogWarning( - "Saving a client failed with {Kind} on {ConstraintName}", - saveResult.Error.Kind, - saveResult.Error.ConstraintName); - return Result.Failure(Error.Conflict( "Client.SaveFailed", "Não foi possível salvar a pessoa. Tente novamente.")); diff --git a/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandHandler.cs index 872c98d6..befcd682 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandHandler.cs @@ -1,5 +1,4 @@ using Admin.SharedKernel; -using Microsoft.Extensions.Logging; using ServicesService.Application.Abstractions; namespace ServicesService.Application.Clients.DeactivateClient; @@ -7,8 +6,7 @@ namespace ServicesService.Application.Clients.DeactivateClient; public sealed class DeactivateClientCommandHandler( IClientRepository clientRepository, IAppointmentRepository appointmentRepository, - IUnitOfWork unitOfWork, - ILogger logger) : ICommandHandler + IUnitOfWork unitOfWork) : ICommandHandler { public async Task> Handle(DeactivateClientCommand command, CancellationToken cancellationToken) { @@ -35,16 +33,11 @@ public async Task> Handle(DeactivateClientCommand command return Result.Failure(inactivateResult.Error.ToApplicationError()); } - await clientRepository.UpdateStatusAsync(client, cancellationToken); + await clientRepository.UpdateAsync(client, cancellationToken); var saveResult = await unitOfWork.SaveChangesAsync(cancellationToken); if (saveResult.IsFailure) { - logger.LogWarning( - "Saving a client failed with {Kind} on {ConstraintName}", - saveResult.Error.Kind, - saveResult.Error.ConstraintName); - return Result.Failure(Error.Conflict( "Client.SaveFailed", "Não foi possível salvar a pessoa. Tente novamente.")); diff --git a/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommandHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommandHandler.cs index 594dce65..ea38831a 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommandHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommandHandler.cs @@ -1,13 +1,11 @@ using Admin.SharedKernel; -using Microsoft.Extensions.Logging; using ServicesService.Application.Abstractions; namespace ServicesService.Application.Clients.ReactivateClient; public sealed class ReactivateClientCommandHandler( IClientRepository clientRepository, - IUnitOfWork unitOfWork, - ILogger logger) : ICommandHandler + IUnitOfWork unitOfWork) : ICommandHandler { public async Task> Handle(ReactivateClientCommand command, CancellationToken cancellationToken) { @@ -22,6 +20,12 @@ public async Task> Handle(ReactivateClientCommand command return ClientResponse.FromClient(client); } + var cpfConflict = await FindCpfConflictAsync(client, cancellationToken); + if (cpfConflict is { } cpfError) + { + return Result.Failure(cpfError); + } + var emailConflict = await FindEmailConflictAsync(client, cancellationToken); if (emailConflict is { } emailError) { @@ -34,16 +38,11 @@ public async Task> Handle(ReactivateClientCommand command return Result.Failure(reactivateResult.Error.ToApplicationError()); } - await clientRepository.UpdateStatusAsync(client, cancellationToken); + await clientRepository.UpdateAsync(client, cancellationToken); var saveResult = await unitOfWork.SaveChangesAsync(cancellationToken); if (saveResult.IsFailure) { - logger.LogWarning( - "Saving a client failed with {Kind} on {ConstraintName}", - saveResult.Error.Kind, - saveResult.Error.ConstraintName); - return Result.Failure(Error.Conflict( "Client.SaveFailed", "Não foi possível salvar a pessoa. Tente novamente.")); @@ -52,6 +51,26 @@ public async Task> Handle(ReactivateClientCommand command return ClientResponse.FromClient(client); } + private async Task FindCpfConflictAsync(Client client, CancellationToken cancellationToken) + { + if (client.Cpf is null) + { + return null; + } + + var otherClientWithSameCpf = await clientRepository.FindByCpfAsync(client.Cpf, client.Id, cancellationToken); + if (otherClientWithSameCpf is null) + { + return null; + } + + return Error.Conflict( + "Client.DuplicateCpf", + "Não é possível reativar esta pessoa porque outra pessoa já usa este CPF. Corrija o CPF deste cadastro e tente novamente.", + field: "Cpf", + meta: ExistingClientMeta(otherClientWithSameCpf)); + } + private async Task FindEmailConflictAsync(Client client, CancellationToken cancellationToken) { if (client.Email is null) @@ -72,10 +91,15 @@ public async Task> Handle(ReactivateClientCommand command "Client.DuplicateEmail", "Não é possível reativar esta pessoa porque outra pessoa ativa já usa este e-mail. Corrija o e-mail deste cadastro e tente novamente.", field: "Email", - meta: new Dictionary - { - ["clientId"] = otherActiveClientWithSameEmail.Id.ToString(), - ["clientName"] = otherActiveClientWithSameEmail.FullName.Value, - }); + meta: ExistingClientMeta(otherActiveClientWithSameEmail)); + } + + private static Dictionary ExistingClientMeta(Client existing) + { + return new Dictionary + { + ["clientId"] = existing.Id.ToString(), + ["clientName"] = existing.FullName.Value, + }; } } diff --git a/backend/services/services-service/ServicesService.Application/Clients/UpdateClient/UpdateClientCommandHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/UpdateClient/UpdateClientCommandHandler.cs index 1f0e8c56..678a0cf8 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/UpdateClient/UpdateClientCommandHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/UpdateClient/UpdateClientCommandHandler.cs @@ -1,5 +1,4 @@ using Admin.SharedKernel; -using Microsoft.Extensions.Logging; using ServicesService.Application.Abstractions; using ServicesService.Domain.Common; using ServicesService.Domain.Entities; @@ -9,8 +8,7 @@ namespace ServicesService.Application.Clients.UpdateClient; public sealed class UpdateClientCommandHandler( IClientRepository clientRepository, IUnitOfWork unitOfWork, - TimeProvider timeProvider, - ILogger logger) : ICommandHandler + TimeProvider timeProvider) : ICommandHandler { public async Task> Handle(UpdateClientCommand command, CancellationToken cancellationToken) { @@ -46,11 +44,6 @@ public async Task> Handle(UpdateClientCommand command, Ca var saveResult = await unitOfWork.SaveChangesAsync(cancellationToken); if (saveResult.IsFailure) { - logger.LogWarning( - "Saving a client failed with {Kind} on {ConstraintName}", - saveResult.Error.Kind, - saveResult.Error.ConstraintName); - return Result.Failure(Error.Conflict( "Client.SaveFailed", "Não foi possível salvar a pessoa. Tente novamente.")); diff --git a/backend/services/services-service/ServicesService.Infrastructure/Persistence/UnitOfWork.cs b/backend/services/services-service/ServicesService.Infrastructure/Persistence/UnitOfWork.cs index ed84d12a..e3742e7a 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Persistence/UnitOfWork.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Persistence/UnitOfWork.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using Npgsql; using ServicesService.Application.Abstractions; @@ -9,10 +10,12 @@ public class UnitOfWork : IUnitOfWork private const string UniqueViolationSqlState = "23505"; private readonly ServicesDataContext _dbContext; + private readonly ILogger _logger; - public UnitOfWork(ServicesDataContext dbContext) + public UnitOfWork(ServicesDataContext dbContext, ILogger logger) { _dbContext = dbContext; + _logger = logger; } public async Task> SaveChangesAsync(CancellationToken cancellationToken) @@ -39,6 +42,11 @@ public async Task> SaveChangesAsync(CancellationToken can await ambientTransaction.RollbackAsync(cancellationToken); } + _logger.LogWarning( + "Saving changes failed with {Kind} on {ConstraintName}", + PersistenceErrorKind.UniqueConstraintViolation, + constraintName); + return PersistenceResult.Failure( new PersistenceError(PersistenceErrorKind.UniqueConstraintViolation, constraintName)); } diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs index adf049df..74508d61 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs @@ -30,20 +30,16 @@ public async Task UpdateAsync(Client client, CancellationToken cancellationToken .Where(contact => contact.ClientId == client.Id) .ToListAsync(cancellationToken); - DbContext.RemoveRange(existingGuardians); - DbContext.RemoveRange(existingReferenceContacts); - DbContext.Entry(client).State = EntityState.Modified; - DbContext.Set().AddRange(client.Guardians); - DbContext.Set().AddRange(client.ReferenceContacts); - } + var currentGuardianIds = client.Guardians.Select(guardian => guardian.Id).ToHashSet(); + var currentReferenceContactIds = client.ReferenceContacts.Select(contact => contact.Id).ToHashSet(); + var existingGuardianIds = existingGuardians.Select(guardian => guardian.Id).ToHashSet(); + var existingReferenceContactIds = existingReferenceContacts.Select(contact => contact.Id).ToHashSet(); - public Task UpdateStatusAsync(Client client, CancellationToken cancellationToken) - { - var entry = DbContext.Entry(client); - entry.State = EntityState.Unchanged; - entry.Property(c => c.Status).IsModified = true; - - return Task.CompletedTask; + DbContext.RemoveRange(existingGuardians.Where(guardian => !currentGuardianIds.Contains(guardian.Id))); + DbContext.RemoveRange(existingReferenceContacts.Where(contact => !currentReferenceContactIds.Contains(contact.Id))); + DbContext.Entry(client).State = EntityState.Modified; + DbContext.Set().AddRange(client.Guardians.Where(guardian => !existingGuardianIds.Contains(guardian.Id))); + DbContext.Set().AddRange(client.ReferenceContacts.Where(contact => !existingReferenceContactIds.Contains(contact.Id))); } public Task FindByCpfAsync(CpfNumber cpf, Guid? excludeClientId, CancellationToken cancellationToken) diff --git a/backend/services/services-service/ServicesService.PersistenceTests/ClientPersistenceTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/ClientPersistenceTests.cs index c9267aad..ff70fce0 100644 --- a/backend/services/services-service/ServicesService.PersistenceTests/ClientPersistenceTests.cs +++ b/backend/services/services-service/ServicesService.PersistenceTests/ClientPersistenceTests.cs @@ -583,7 +583,7 @@ public async Task Update_ReplacesEveryContactInOneSave_AndSoftDeletesThePrevious } } - private static async Task ChangeStatusThroughTheRepository( + private static async Task ChangeStatusAndUpdate( string databaseName, Guid tenantId, Guid clientId, @@ -594,12 +594,12 @@ private static async Task ChangeStatusThroughTheRepository( var loaded = (await repository.GetByIdAsync(clientId, CancellationToken.None))!; transition(loaded).IsSuccess.Should().BeTrue(); - await repository.UpdateStatusAsync(loaded, CancellationToken.None); + await repository.UpdateAsync(loaded, CancellationToken.None); await context.SaveChangesAsync(CancellationToken.None); } [Fact] - public async Task UpdateStatus_ChangesTheStatusAndKeepsTheSameContactRows() + public async Task Update_AfterAStatusChange_KeepsTheSameContactRows() { var databaseName = Guid.NewGuid().ToString(); var tenantId = Guid.NewGuid(); @@ -616,7 +616,7 @@ public async Task UpdateStatus_ChangesTheStatusAndKeepsTheSameContactRows() var guardianIds = client.Guardians.Select(guardian => guardian.Id).ToArray(); var referenceContactIds = client.ReferenceContacts.Select(contact => contact.Id).ToArray(); - await ChangeStatusThroughTheRepository(databaseName, tenantId, client.Id, loaded => loaded.Inactivate()); + await ChangeStatusAndUpdate(databaseName, tenantId, client.Id, loaded => loaded.Inactivate()); await using var verification = CreateContext(databaseName, tenantId); var stored = await verification.Clients @@ -638,43 +638,7 @@ public async Task UpdateStatus_ChangesTheStatusAndKeepsTheSameContactRows() } [Fact] - public async Task UpdateStatus_LeavesAColumnChangedByAnotherSessionAfterTheLoadUntouched() - { - var databaseName = Guid.NewGuid().ToString(); - var tenantId = Guid.NewGuid(); - var client = NewClient("Maria Souza", guardians: [Guardian()]); - await using (var context = CreateContext(databaseName, tenantId)) - { - await Save(context, client); - } - - await using (var context = CreateContext(databaseName, tenantId)) - { - var repository = new ClientRepository(context); - var loaded = (await repository.GetByIdAsync(client.Id, CancellationToken.None))!; - - await using (var other = CreateContext(databaseName, tenantId)) - { - var tracked = await other.Clients.AsTracking().SingleAsync(c => c.Id == client.Id, TestContext.Current.CancellationToken); - other.Entry(tracked).Property(c => c.FullName).CurrentValue = FullName.Create("Maria Lima").Value; - await other.SaveChangesAsync(TestContext.Current.CancellationToken); - } - - loaded.Inactivate().IsSuccess.Should().BeTrue(); - await repository.UpdateStatusAsync(loaded, CancellationToken.None); - await context.SaveChangesAsync(TestContext.Current.CancellationToken); - } - - await using (var context = CreateContext(databaseName, tenantId)) - { - var stored = await context.Clients.SingleAsync(c => c.Id == client.Id, TestContext.Current.CancellationToken); - stored.Status.Should().Be(ClientStatus.Inactive); - stored.FullName.Value.Should().Be("Maria Lima"); - } - } - - [Fact] - public async Task UpdateStatus_InactivatesAndReactivatesTheSameClient_FreeingAndTakingTheEmailBack() + public async Task Update_InactivatesAndReactivatesTheSameClient_FreeingAndTakingTheEmailBack() { var databaseName = Guid.NewGuid().ToString(); var tenantId = Guid.NewGuid(); @@ -684,7 +648,7 @@ public async Task UpdateStatus_InactivatesAndReactivatesTheSameClient_FreeingAnd await Save(context, client); } - await ChangeStatusThroughTheRepository(databaseName, tenantId, client.Id, loaded => loaded.Inactivate()); + await ChangeStatusAndUpdate(databaseName, tenantId, client.Id, loaded => loaded.Inactivate()); await using (var context = CreateContext(databaseName, tenantId)) { @@ -693,7 +657,7 @@ public async Task UpdateStatus_InactivatesAndReactivatesTheSameClient_FreeingAnd (await repository.FindActiveByEmailAsync(Email("maria@example.com"), null, CancellationToken.None)).Should().BeNull(); } - await ChangeStatusThroughTheRepository(databaseName, tenantId, client.Id, loaded => loaded.Reactivate()); + await ChangeStatusAndUpdate(databaseName, tenantId, client.Id, loaded => loaded.Reactivate()); await using (var context = CreateContext(databaseName, tenantId)) { diff --git a/backend/services/services-service/ServicesService.PersistenceTests/UnitOfWorkTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/UnitOfWorkTests.cs new file mode 100644 index 00000000..e7e78f5c --- /dev/null +++ b/backend/services/services-service/ServicesService.PersistenceTests/UnitOfWorkTests.cs @@ -0,0 +1,116 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Logging; +using Npgsql; +using ServicesService.Application.Abstractions; +using ServicesService.Infrastructure.Persistence; + +namespace ServicesService.PersistenceTests; + +public class UnitOfWorkTests +{ + private static ServicesDataContext CreateContext(params IInterceptor[] interceptors) + { + var tenantProvider = Substitute.For(); + tenantProvider.TenantId.Returns(Guid.NewGuid()); + + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .AddInterceptors(interceptors) + .Options; + + return new ServicesDataContext(options, tenantProvider); + } + + private static DbUpdateException PostgresFailure(string sqlState, string? constraintName) + { + var postgresException = new PostgresException( + "rejected", + "ERROR", + "ERROR", + sqlState, + constraintName: constraintName); + + return new DbUpdateException("rejected", postgresException); + } + + [Fact] + public async Task SaveChanges_WhenTheDatabaseRejectsAUniqueValue_ReturnsTheFailureAndLogsItAsAWarning() + { + var logger = new CapturingLogger(); + await using var context = CreateContext(new FailingSaveInterceptor(PostgresFailure("23505", "IX_Clients_TenantId_Email"))); + var unitOfWork = new UnitOfWork(context, logger); + + var result = await unitOfWork.SaveChangesAsync(CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.Error.Kind.Should().Be(PersistenceErrorKind.UniqueConstraintViolation); + result.Error.ConstraintName.Should().Be("IX_Clients_TenantId_Email"); + var entry = logger.Entries.Should().ContainSingle().Subject; + entry.Level.Should().Be(LogLevel.Warning); + entry.Message.Should().Contain("UniqueConstraintViolation").And.Contain("IX_Clients_TenantId_Email"); + } + + [Fact] + public async Task SaveChanges_WhenItSucceeds_LogsNothing() + { + var logger = new CapturingLogger(); + await using var context = CreateContext(); + var unitOfWork = new UnitOfWork(context, logger); + + var result = await unitOfWork.SaveChangesAsync(CancellationToken.None); + + result.IsSuccess.Should().BeTrue(); + logger.Entries.Should().BeEmpty(); + } + + [Fact] + public async Task SaveChanges_WhenTheFailureIsNotAUniqueViolation_LetsItPropagateWithoutLogging() + { + var logger = new CapturingLogger(); + await using var context = CreateContext(new FailingSaveInterceptor(PostgresFailure("23503", "FK_Anything"))); + var unitOfWork = new UnitOfWork(context, logger); + + var act = async () => await unitOfWork.SaveChangesAsync(CancellationToken.None); + + await act.Should().ThrowAsync(); + logger.Entries.Should().BeEmpty(); + } + + private sealed class FailingSaveInterceptor(Exception exception) : SaveChangesInterceptor + { + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + throw exception; + } + } + + private sealed class CapturingLogger : ILogger + { + public List<(LogLevel Level, string Message)> Entries { get; } = []; + + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add((logLevel, formatter(state, exception))); + } + } +} diff --git a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandHandlerTests.cs index 2b1510bb..45c21bf0 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandHandlerTests.cs @@ -1,5 +1,4 @@ using Admin.SharedKernel; -using Microsoft.Extensions.Logging; using ServicesService.Application.Abstractions; using ServicesService.Application.Clients.CreateClient; @@ -11,7 +10,6 @@ public class CreateClientCommandHandlerTests private readonly IClientRepository _repository = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); - private readonly ILogger _logger = Substitute.For>(); public CreateClientCommandHandlerTests() { @@ -23,7 +21,7 @@ public CreateClientCommandHandlerTests() } private CreateClientCommandHandler Handler(DateTimeOffset? utcNow = null) => - new(_repository, _unitOfWork, new FixedTimeProvider(utcNow ?? NoonUtc), _logger); + new(_repository, _unitOfWork, new FixedTimeProvider(utcNow ?? NoonUtc)); private static CreateClientCommand Command( string fullName = "Maria Souza", diff --git a/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs index 46a1bfa8..c6aee97c 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs @@ -1,5 +1,4 @@ using Admin.SharedKernel; -using Microsoft.Extensions.Logging; using ServicesService.Application.Abstractions; using ServicesService.Application.Clients.DeactivateClient; @@ -10,19 +9,18 @@ public class DeactivateClientCommandHandlerTests private readonly IClientRepository _repository = Substitute.For(); private readonly IAppointmentRepository _appointments = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); - private readonly ILogger _logger = Substitute.For>(); private readonly Client _client = ClientWithContacts(); public DeactivateClientCommandHandlerTests() { _repository.GetByIdAsync(_client.Id, Arg.Any()).Returns(Task.FromResult(_client)); - _repository.UpdateStatusAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + _repository.UpdateAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); _appointments.HasUpcomingAppointmentsAsync(Arg.Any(), Arg.Any()) .Returns(Task.FromResult(false)); _unitOfWork.SaveChangesAsync(Arg.Any()).Returns(PersistenceResult.Success(1)); } - private DeactivateClientCommandHandler Handler() => new(_repository, _appointments, _unitOfWork, _logger); + private DeactivateClientCommandHandler Handler() => new(_repository, _appointments, _unitOfWork); private static Client ClientWithContacts() { @@ -37,7 +35,6 @@ private static Client ClientWithContacts() private async Task AssertNothingWasPersisted() { - await _repository.DidNotReceive().UpdateStatusAsync(Arg.Any(), Arg.Any()); await _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); _repository.DidNotReceive().Add(Arg.Any()); await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); @@ -52,9 +49,8 @@ public async Task Handle_WithNoUpcomingAppointment_InactivatesTheClientAndSavesI result.Value.Id.Should().Be(_client.Id); result.Value.Status.Should().Be("inactive"); _client.Status.Should().Be(ClientStatus.Inactive); - await _repository.Received(1).UpdateStatusAsync(_client, Arg.Any()); + await _repository.Received(1).UpdateAsync(_client, Arg.Any()); await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); - await _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); _repository.DidNotReceive().Add(Arg.Any()); } diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ReactivateClient/ReactivateClientCommandHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ReactivateClient/ReactivateClientCommandHandlerTests.cs index 6962e20c..38675984 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ReactivateClient/ReactivateClientCommandHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ReactivateClient/ReactivateClientCommandHandlerTests.cs @@ -1,5 +1,4 @@ using Admin.SharedKernel; -using Microsoft.Extensions.Logging; using ServicesService.Application.Abstractions; using ServicesService.Application.Clients.ReactivateClient; @@ -9,19 +8,20 @@ public class ReactivateClientCommandHandlerTests { private readonly IClientRepository _repository = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); - private readonly ILogger _logger = Substitute.For>(); private readonly Client _client = InactiveClientWithContacts(); public ReactivateClientCommandHandlerTests() { _repository.GetByIdAsync(_client.Id, Arg.Any()).Returns(Task.FromResult(_client)); - _repository.UpdateStatusAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + _repository.UpdateAsync(Arg.Any(), Arg.Any()).Returns(Task.CompletedTask); + _repository.FindByCpfAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(null)); _repository.FindActiveByEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromResult(null)); _unitOfWork.SaveChangesAsync(Arg.Any()).Returns(PersistenceResult.Success(1)); } - private ReactivateClientCommandHandler Handler() => new(_repository, _unitOfWork, _logger); + private ReactivateClientCommandHandler Handler() => new(_repository, _unitOfWork); private static Client InactiveClientWithContacts(EmailAddress? email = null) { @@ -29,6 +29,7 @@ private static Client InactiveClientWithContacts(EmailAddress? email = null) ClientTestData.Data( ClientTestData.Name("Paula Rocha"), email: email ?? ClientTestData.Email("paula@example.com"), + cpf: ClientTestData.Cpf(), guardians: [ClientTestData.Guardian("Ana Souza"), ClientTestData.Guardian("Bia Souza")], referenceContacts: [ClientTestData.ReferenceContact()]), ClientTestData.Today).Value; @@ -38,14 +39,13 @@ private static Client InactiveClientWithContacts(EmailAddress? email = null) private async Task AssertNothingWasPersisted() { - await _repository.DidNotReceive().UpdateStatusAsync(Arg.Any(), Arg.Any()); await _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); _repository.DidNotReceive().Add(Arg.Any()); await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); } [Fact] - public async Task Handle_WithAnEmailNoOtherActiveClientUses_ReactivatesTheClientAndSavesItsStatus() + public async Task Handle_WithNoConflict_ReactivatesTheClientAndSavesItsStatus() { var result = await Handler().Handle(new ReactivateClientCommand(_client.Id), CancellationToken.None); @@ -53,9 +53,8 @@ public async Task Handle_WithAnEmailNoOtherActiveClientUses_ReactivatesTheClient result.Value.Id.Should().Be(_client.Id); result.Value.Status.Should().Be("active"); _client.Status.Should().Be(ClientStatus.Active); - await _repository.Received(1).UpdateStatusAsync(_client, Arg.Any()); + await _repository.Received(1).UpdateAsync(_client, Arg.Any()); await _unitOfWork.Received(1).SaveChangesAsync(Arg.Any()); - await _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); _repository.DidNotReceive().Add(Arg.Any()); } @@ -71,7 +70,15 @@ await _repository.Received(1).FindActiveByEmailAsync( } [Fact] - public async Task Handle_WithoutAnEmail_ReactivatesWithoutCheckingUniqueness() + public async Task Handle_ChecksTheCpfOfTheClientExcludingTheClientItself() + { + await Handler().Handle(new ReactivateClientCommand(_client.Id), CancellationToken.None); + + await _repository.Received(1).FindByCpfAsync(ClientTestData.Cpf(), _client.Id, Arg.Any()); + } + + [Fact] + public async Task Handle_WithoutEmailAndCpf_ReactivatesWithoutCheckingUniqueness() { var client = Client.Create(ClientTestData.Data(), ClientTestData.Today).Value; client.Inactivate(); @@ -82,6 +89,7 @@ public async Task Handle_WithoutAnEmail_ReactivatesWithoutCheckingUniqueness() result.IsSuccess.Should().BeTrue(); result.Value.Status.Should().Be("active"); await _repository.DidNotReceive().FindActiveByEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + await _repository.DidNotReceive().FindByCpfAsync(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -95,6 +103,7 @@ public async Task Handle_KeepsTheDataAndEveryContactOfTheClient() var response = result.Value; response.FullName.Should().Be("Paula Rocha"); response.Email.Should().Be("paula@example.com"); + response.Cpf.Should().Be(ClientTestData.ValidCpfDigits); response.Guardians.Select(guardian => guardian.Id).Should().BeEquivalentTo(guardianIds); response.ReferenceContacts.Should().ContainSingle().Which.Id.Should().Be(referenceContactId); } @@ -124,6 +133,45 @@ public async Task Handle_WithTheEmailOfAnotherActiveClient_ReturnsAConflictPoint await AssertNothingWasPersisted(); } + [Fact] + public async Task Handle_WithTheCpfOfAnotherClient_ReturnsAConflictPointingAtItAndKeepsTheClientInactive() + { + var other = ClientTestData.ExistingClient(); + _repository.FindByCpfAsync(ClientTestData.Cpf(), _client.Id, Arg.Any()) + .Returns(Task.FromResult(other)); + + var result = await Handler().Handle(new ReactivateClientCommand(_client.Id), CancellationToken.None); + + result.IsFailure.Should().BeTrue(); + result.Error.Type.Should().Be(ErrorType.Conflict); + result.Error.Code.Should().Be("Client.DuplicateCpf"); + result.Error.FieldErrors!.Keys.Should().Equal("Cpf"); + var fieldError = result.Error.FieldErrors["Cpf"].Should().ContainSingle().Subject; + fieldError.Message.Should().Be( + "Não é possível reativar esta pessoa porque outra pessoa já usa este CPF. Corrija o CPF deste cadastro e tente novamente."); + fieldError.Meta.Should().BeEquivalentTo(new Dictionary + { + ["clientId"] = other.Id.ToString(), + ["clientName"] = "Paula Rocha", + }); + _client.Status.Should().Be(ClientStatus.Inactive); + await AssertNothingWasPersisted(); + } + + [Fact] + public async Task Handle_WithBothConflicts_ReportsTheCpfOnly() + { + _repository.FindByCpfAsync(ClientTestData.Cpf(), _client.Id, Arg.Any()) + .Returns(Task.FromResult(ClientTestData.ExistingClient())); + _repository.FindActiveByEmailAsync(ClientTestData.Email("paula@example.com"), _client.Id, Arg.Any()) + .Returns(Task.FromResult(ClientTestData.ExistingClient())); + + var result = await Handler().Handle(new ReactivateClientCommand(_client.Id), CancellationToken.None); + + result.Error.Code.Should().Be("Client.DuplicateCpf"); + await _repository.DidNotReceive().FindActiveByEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); + } + [Fact] public async Task Handle_WithAnIdTheRepositoryDoesNotKnow_ReturnsNotFound() { @@ -136,6 +184,7 @@ public async Task Handle_WithAnIdTheRepositoryDoesNotKnow_ReturnsNotFound() result.Error.Type.Should().Be(ErrorType.NotFound); result.Error.Code.Should().Be("Client.NotFound"); result.Error.Message.Should().Be("A pessoa não foi encontrada."); + await _repository.DidNotReceive().FindByCpfAsync(Arg.Any(), Arg.Any(), Arg.Any()); await _repository.DidNotReceive().FindActiveByEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); await AssertNothingWasPersisted(); } @@ -151,6 +200,7 @@ public async Task Handle_WhenTheClientIsAlreadyActive_ReturnsItsCurrentStateWith result.Value.Id.Should().Be(_client.Id); result.Value.Status.Should().Be("active"); result.Value.Guardians.Should().HaveCount(2); + await _repository.DidNotReceive().FindByCpfAsync(Arg.Any(), Arg.Any(), Arg.Any()); await _repository.DidNotReceive().FindActiveByEmailAsync(Arg.Any(), Arg.Any(), Arg.Any()); await AssertNothingWasPersisted(); } diff --git a/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandHandlerTests.cs index 34ab8f2a..c20fccc0 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandHandlerTests.cs @@ -1,5 +1,4 @@ using Admin.SharedKernel; -using Microsoft.Extensions.Logging; using ServicesService.Application.Abstractions; using ServicesService.Application.Clients.UpdateClient; using ServicesService.Domain.Entities; @@ -13,7 +12,6 @@ public class UpdateClientCommandHandlerTests private readonly IClientRepository _repository = Substitute.For(); private readonly IUnitOfWork _unitOfWork = Substitute.For(); - private readonly ILogger _logger = Substitute.For>(); private readonly Client _client = ClientWithContacts(); public UpdateClientCommandHandlerTests() @@ -28,7 +26,7 @@ public UpdateClientCommandHandlerTests() } private UpdateClientCommandHandler Handler(DateTimeOffset? utcNow = null) => - new(_repository, _unitOfWork, new FixedTimeProvider(utcNow ?? NoonUtc), _logger); + new(_repository, _unitOfWork, new FixedTimeProvider(utcNow ?? NoonUtc)); private static Client ClientWithContacts() { diff --git a/docs/adr/0048-database-failures-are-generic-to-the-user.md b/docs/adr/0048-database-failures-are-generic-to-the-user.md index ee7c75e0..8f3485ec 100644 --- a/docs/adr/0048-database-failures-are-generic-to-the-user.md +++ b/docs/adr/0048-database-failures-are-generic-to-the-user.md @@ -19,6 +19,10 @@ entity: `409 .SaveFailed`, "Não foi possível salvar … Tente novament failure and the constraint name go to the log (`Warning`), not to the response. Per-field answers come only from the handler's pre-checks, and a retry passes through them again. +> **2026-10 update:** the log moved from the handlers to `UnitOfWork` +> ([ADR 0062](0062-clients-deactivate-and-reactivate.md)): it writes the `Warning` once for every failed save, and the +> clients handlers no longer take an `ILogger`. The rest of this decision stands. + Clients follows this now (`CreateClientCommandHandler`). Tags, Categories and Services drop their persistence error mappers when they are next touched. diff --git a/docs/adr/0062-clients-deactivate-and-reactivate.md b/docs/adr/0062-clients-deactivate-and-reactivate.md index 8da55306..17a82f05 100644 --- a/docs/adr/0062-clients-deactivate-and-reactivate.md +++ b/docs/adr/0062-clients-deactivate-and-reactivate.md @@ -8,8 +8,8 @@ situation) and [ADR 0060](0060-clients-read-by-id-contract.md) (the response) [ADR 0044](0044-clients-aggregate-uniqueness-and-conflict-contract.md) gave `Client` a `ClientStatus { Active, Inactive }` and no transition. #157 (deactivate) and #158 (reactivate) add the two transitions as endpoints. Three things were open: the deactivation rule needs appointments, and the appointments module (#153) does not exist -yet; what a repeated action answers; and how a status change is written without disturbing the contacts that -[ADR 0056](0056-clients-edit-replaces-contact-composition.md) makes the edit replace. +yet; what a repeated action answers; and how a status change is saved through the same `UpdateAsync` as the edit, +whose contacts [ADR 0056](0056-clients-edit-replaces-contact-composition.md) makes it replace. ## Decision @@ -27,11 +27,14 @@ with the current state; the handler returns before reading appointments or e-mai changes, not even `UpdatedAt`. This was a product choice over a `409` with the named errors; the domain still refuses the repeated transition, the handler just never reaches it. -**Persisting a status alone.** `IClientRepository.UpdateStatusAsync` attaches the loaded client and marks the `Status` -column as modified. `UpdateAsync` cannot be used: it replaces the contact composition, which would soft-delete and -re-create every contact on a status change, and `Entry(client).State = Modified` would write every column from the -snapshot the handler loaded, overwriting an edit saved meanwhile. `ClientPersistenceTests` prove both, including the -second one against the all-columns write. +**A status change is saved with `UpdateAsync`, like the edit.** There is no second repository method for it. +`UpdateAsync` used to remove every stored contact and add every contact of the aggregate, which is right for the edit +(it mints new contact ids) and throws for an aggregate whose contacts did not change: the loaded contacts are added +under ids that were just marked for removal. It now compares by id and stages only the difference: it removes the stored +contacts the aggregate no longer holds and adds the ones the database does not have. The edit still replaces +everything, since every contact it builds is new; a status change touches no contact row. The root is saved whole, so a +status change writes every column from the snapshot the handler loaded, the same exposure to a simultaneous edit that +the `PUT` already has and that this product accepts for a client record. **Deactivation is blocked by upcoming appointments (#157).** The handler asks a port, `IAppointmentRepository.HasUpcomingAppointmentsAsync(clientId)`, and the adapter decides what "upcoming" means with @@ -58,6 +61,20 @@ Another **inactive** person with the same e-mail does not block. A person with n corrected through the existing edit, which accepts an inactive person and checks the e-mail only once the person is active. +**Reactivation also checks the CPF, defensively.** The CPF index covers active and inactive people alike and +reactivating changes neither the CPF nor the deletion, so the database cannot produce a CPF conflict here and the +normal flow never reaches the check. It is kept because the create and the edit run the same pre-check and a person whose +data was changed outside the application should not be reactivated into a duplicate. It runs first, with +`FindByCpfAsync(cpf, excludeClientId: self)`, one conflict per answer as in ADR 0044, and answers +`409 Client.DuplicateCpf`, keyed `cpf`, with the same `meta` and a message of its own that says to fix this person's CPF. + +**A failed save is logged by the unit of work.** `UnitOfWork` writes the `Warning` with the kind and the +constraint name once for every save the database rejects; the four clients handlers (create, edit, deactivate, +reactivate) no longer take an `ILogger` and only answer `409 Client.SaveFailed` +([ADR 0048](0048-database-failures-are-generic-to-the-user.md)). Tags, Categories and Services keep their persistence +error mappers, which log a constraint they do not recognise; for that one case the same failed save appears twice in the +log until those slices are converted. + **Concurrency.** No migration. The e-mail rule is already the partial unique index `IX_Clients_TenantId_Email ... WHERE "Status" = 'Active' AND "DeletedAt" IS NULL`, so setting a row back to `Active` is a write the database rejects when another active row has the e-mail. Per @@ -68,24 +85,38 @@ retry gets the specific `Client.DuplicateEmail`. Two parallel deactivations of t - Wire: two new operations on the clients resource; `services-api.d.ts` is regenerated in this change. - The handler tests of the edit and of the read that made a client inactive by reflection now call `Inactivate()`. +- `UnitOfWorkTests` cover the log: a unique violation returns the failure and logs one warning, a success logs nothing, + and another database failure propagates without a log. +- Entity Framework Core also logs each rejected command at `Error` on its own, as it already did for the create and the + edit races; that line is not ours. - Accepted limit: between the appointment read and the save, #153 could create an appointment for the person. Nothing in this service's database can forbid it. #153 already has to re-check that the person is active when it saves, which closes the window from its side. - The interface actions (**Desativar**, **Reativar**, the confirmation and the messages) are the frontend's and are not part of this change. -Verified by hand on a disposable PostgreSQL 18 with the real services and a real login, 51 checks: deactivate and -reactivate with contacts, contact rows untouched (none inserted, none soft-deleted, none stamped), repetition writing -nothing, unknown, other-tenant and deleted ids, the e-mail conflict and its `meta`, the fix-then-reactivate path, -inactive-versus-inactive, 12 parallel reactivation races (always one `200` and one `Client.SaveFailed`, never two active -rows with the e-mail), 6 parallel deactivations, and the OpenAPI document. +Verified by hand on a disposable PostgreSQL 18 with the real services and a real login, 55 checks: deactivate and +reactivate with contacts, contact rows untouched (none inserted, none soft-deleted, none stamped), a person with a CPF +going round trip, repetition writing nothing, unknown, other-tenant and deleted ids, the e-mail conflict and its `meta`, +the fix-then-reactivate path with the `PUT` still replacing the contacts, inactive-versus-inactive, 12 parallel +reactivation races (always one `200` and one conflict, `Client.SaveFailed` or, when the race was not lost, +`Client.DuplicateEmail`, never two active rows with the e-mail), 6 parallel deactivations, and the OpenAPI document. +The service log carried one `UnitOfWork` warning per lost race and no handler message. ## Considered and rejected - **A `409` on a repeated action.** Follows the letter of "refuse an invalid transition", but a second click or a second tab becomes an error for a state the user wanted. -- **`UpdateAsync` for the status.** It replaces the contacts (ADR 0056). -- **`Entry(client).State = Modified`.** Writes every column from a stale snapshot. +- **`UpdateStatusAsync`, a second repository method that writes only the `Status` column** (the first version of + this change). `UpdateAsync` threw for unchanged contacts, so it was built to avoid that and to keep a simultaneous + edit; changed after review, because a client record has no need for the optimisation and one way to update the + aggregate is simpler. `UpdateAsync` was fixed instead. +- **`UpdateAsync` as it was.** It throws when the contacts did not change. +- **Rewriting the contacts on a status change** (calling the edit with the same data). It soft-deletes and re-creates + every contact row to change one column. +- **The log in each handler** (the first version). Four copies of the same `if`; moved to `UnitOfWork` after review. +- **No CPF check on reactivation.** Right in theory, since the index makes a conflict impossible, but it left this + handler different from the create and the edit; added after review. - **A minimal `Appointment` entity in this change.** It would decide part of #153's model (situations, names, keys) so the check could run against a table. - **Delivering only #158 and waiting for #153 to do #157.** The issue sequences it that way, but it left the endpoint, From de7a7a984b8daee3c35bb9e9e4711af748dd0c00 Mon Sep 17 00:00:00 2001 From: Everton William Thoele Schuster Date: Thu, 8 Oct 2026 08:59:04 -0300 Subject: [PATCH 4/4] Fix client sync and clean imports Updated client repository update logic to synchronize guardians and reference contacts instead of manually tracking stale entries, keeping client contact records consistent during edits. This also removes redundant usings and stale documentation imports across backend services and fixes a few compile-time cleanups in the identity and shared kernel projects. --- backend/ServiceDefaults/Extensions.cs | 1 - .../Controllers/AuthorizationController.cs | 3 +- .../IdentityService.Api/Program.cs | 2 -- .../Setup/DocumentationExtensions.cs | 1 - .../DependencyInjection.cs | 2 +- .../Identity/TenantClaimsPrincipalFactory.cs | 2 +- .../20260725172020_InitialCreate.cs | 3 +- .../Setup/DocumentationExtensions.cs | 1 - .../UpdateClientCommandHandler.cs | 2 -- .../DependencyInjection.cs | 2 +- .../TenantSequenceConfiguration.cs | 1 - .../20260725172036_InitialCreate.cs | 3 +- .../Migrations/20261002174511_AddClients.cs | 3 +- .../Repositories/ClientRepository.cs | 30 ++++++++++--------- .../ClientPersistenceTests.cs | 2 +- .../Clients/ClientUpdateTests.cs | 4 --- .../CreateClientCommandBindingTests.cs | 2 +- ...reateClientCommandBirthDateBindingTests.cs | 2 +- .../CreateClientCommandCpfBindingTests.cs | 2 +- .../CreateClientCommandEmailBindingTests.cs | 2 +- ...CreateClientCommandFullNameBindingTests.cs | 2 +- .../CreateClientCommandNotesBindingTests.cs | 2 +- .../CreateClientCommandPhoneBindingTests.cs | 2 +- ...CreateClientCommandPurposesBindingTests.cs | 2 +- .../GetClientByIdQueryHandlerTests.cs | 1 - .../UpdateClientCommandBindingTests.cs | 2 +- .../UpdateClientCommandHandlerTests.cs | 2 -- .../ServicesService.Tests/WireJson.cs | 2 +- .../ReadableConsoleTests.cs | 2 +- .../ReadableLoggingExtensionsTests.cs | 2 +- .../shared/Admin.Logging/ReadableConsole.cs | 2 +- .../ReadableLoggingExtensions.cs | 2 +- .../ApiProblemDetailsFactory.cs | 2 +- .../GenericExceptionHandler.cs | 1 - .../DateValueObjectConverter.cs | 2 +- .../ModelBuilderExtensions.cs | 2 +- .../RepositoryBase.cs | 2 +- .../StringValueObjectConverter.cs | 2 +- .../GenericExceptionHandlerTests.cs | 2 +- .../ModelStateErrorMapperTests.cs | 2 +- .../MvcBuilderExtensionsTests.cs | 2 +- .../OpenApiOptionsExtensionsTests.cs | 2 +- .../ServiceCollectionExtensionsTests.cs | 2 +- .../ValueObjectContract.cs | 2 -- .../shared/Admin.SharedKernel/Dispatcher.cs | 1 - .../ServiceCollectionExtensions.cs | 2 +- 46 files changed, 49 insertions(+), 70 deletions(-) diff --git a/backend/ServiceDefaults/Extensions.cs b/backend/ServiceDefaults/Extensions.cs index c3e51742..24382de9 100644 --- a/backend/ServiceDefaults/Extensions.cs +++ b/backend/ServiceDefaults/Extensions.cs @@ -3,7 +3,6 @@ using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; -using Microsoft.Extensions.ServiceDiscovery; using OpenTelemetry; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; diff --git a/backend/services/identity-service/IdentityService.Api/Controllers/AuthorizationController.cs b/backend/services/identity-service/IdentityService.Api/Controllers/AuthorizationController.cs index 8c7163f4..d55571f2 100644 --- a/backend/services/identity-service/IdentityService.Api/Controllers/AuthorizationController.cs +++ b/backend/services/identity-service/IdentityService.Api/Controllers/AuthorizationController.cs @@ -1,12 +1,11 @@ -using System.Security.Claims; using IdentityService.Infrastructure.Identity; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Authentication; -using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using OpenIddict.Abstractions; using OpenIddict.Server.AspNetCore; +using System.Security.Claims; namespace IdentityService.Api.Controllers; diff --git a/backend/services/identity-service/IdentityService.Api/Program.cs b/backend/services/identity-service/IdentityService.Api/Program.cs index d4209f1c..c836b880 100644 --- a/backend/services/identity-service/IdentityService.Api/Program.cs +++ b/backend/services/identity-service/IdentityService.Api/Program.cs @@ -6,8 +6,6 @@ using IdentityService.Application; using IdentityService.Infrastructure; using OpenIddict.Abstractions; -using OpenIddict.Server.AspNetCore; -using OpenIddict.Validation.AspNetCore; var builder = WebApplication.CreateBuilder(args); diff --git a/backend/services/identity-service/IdentityService.Api/Setup/DocumentationExtensions.cs b/backend/services/identity-service/IdentityService.Api/Setup/DocumentationExtensions.cs index 90b6ffd6..3e3530bc 100644 --- a/backend/services/identity-service/IdentityService.Api/Setup/DocumentationExtensions.cs +++ b/backend/services/identity-service/IdentityService.Api/Setup/DocumentationExtensions.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; using Scalar.AspNetCore; diff --git a/backend/services/identity-service/IdentityService.Application/DependencyInjection.cs b/backend/services/identity-service/IdentityService.Application/DependencyInjection.cs index 16c8273c..75713d97 100644 --- a/backend/services/identity-service/IdentityService.Application/DependencyInjection.cs +++ b/backend/services/identity-service/IdentityService.Application/DependencyInjection.cs @@ -1,7 +1,7 @@ -using System.Reflection; using Admin.SharedKernel; using FluentValidation; using Microsoft.Extensions.DependencyInjection; +using System.Reflection; namespace IdentityService.Application; diff --git a/backend/services/identity-service/IdentityService.Infrastructure/Identity/TenantClaimsPrincipalFactory.cs b/backend/services/identity-service/IdentityService.Infrastructure/Identity/TenantClaimsPrincipalFactory.cs index 54f18fd0..f74c3818 100644 --- a/backend/services/identity-service/IdentityService.Infrastructure/Identity/TenantClaimsPrincipalFactory.cs +++ b/backend/services/identity-service/IdentityService.Infrastructure/Identity/TenantClaimsPrincipalFactory.cs @@ -1,6 +1,6 @@ -using System.Security.Claims; using Microsoft.AspNetCore.Identity; using Microsoft.Extensions.Options; +using System.Security.Claims; namespace IdentityService.Infrastructure.Identity; diff --git a/backend/services/identity-service/IdentityService.Infrastructure/Persistence/Migrations/20260725172020_InitialCreate.cs b/backend/services/identity-service/IdentityService.Infrastructure/Persistence/Migrations/20260725172020_InitialCreate.cs index 35ee2d87..33fa35b1 100644 --- a/backend/services/identity-service/IdentityService.Infrastructure/Persistence/Migrations/20260725172020_InitialCreate.cs +++ b/backend/services/identity-service/IdentityService.Infrastructure/Persistence/Migrations/20260725172020_InitialCreate.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; #nullable disable diff --git a/backend/services/services-service/ServicesService.Api/Setup/DocumentationExtensions.cs b/backend/services/services-service/ServicesService.Api/Setup/DocumentationExtensions.cs index cdaf854f..b0ee6cc0 100644 --- a/backend/services/services-service/ServicesService.Api/Setup/DocumentationExtensions.cs +++ b/backend/services/services-service/ServicesService.Api/Setup/DocumentationExtensions.cs @@ -1,6 +1,5 @@ using Admin.Identity.Client; using Admin.SharedKernel.AspNetCore; -using Microsoft.AspNetCore.OpenApi; using Microsoft.OpenApi; using Scalar.AspNetCore; using System.Text.Json.Nodes; diff --git a/backend/services/services-service/ServicesService.Application/Clients/UpdateClient/UpdateClientCommandHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/UpdateClient/UpdateClientCommandHandler.cs index 678a0cf8..4ea5c619 100644 --- a/backend/services/services-service/ServicesService.Application/Clients/UpdateClient/UpdateClientCommandHandler.cs +++ b/backend/services/services-service/ServicesService.Application/Clients/UpdateClient/UpdateClientCommandHandler.cs @@ -1,7 +1,5 @@ using Admin.SharedKernel; using ServicesService.Application.Abstractions; -using ServicesService.Domain.Common; -using ServicesService.Domain.Entities; namespace ServicesService.Application.Clients.UpdateClient; diff --git a/backend/services/services-service/ServicesService.Application/DependencyInjection.cs b/backend/services/services-service/ServicesService.Application/DependencyInjection.cs index 16284b8b..5720f48a 100644 --- a/backend/services/services-service/ServicesService.Application/DependencyInjection.cs +++ b/backend/services/services-service/ServicesService.Application/DependencyInjection.cs @@ -1,8 +1,8 @@ -using System.Reflection; using Admin.SharedKernel; using FluentValidation; using Microsoft.Extensions.DependencyInjection; using ServicesService.Application.Services; +using System.Reflection; namespace ServicesService.Application; diff --git a/backend/services/services-service/ServicesService.Infrastructure/Persistence/Configurations/TenantSequenceConfiguration.cs b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Configurations/TenantSequenceConfiguration.cs index 1024ed92..eba3beee 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Persistence/Configurations/TenantSequenceConfiguration.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Configurations/TenantSequenceConfiguration.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; -using ServicesService.Infrastructure.Persistence; namespace ServicesService.Infrastructure.Persistence.Configurations; diff --git a/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20260725172036_InitialCreate.cs b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20260725172036_InitialCreate.cs index 7ed38d62..aa438094 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20260725172036_InitialCreate.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20260725172036_InitialCreate.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20261002174511_AddClients.cs b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20261002174511_AddClients.cs index c211655a..c38e8d86 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20261002174511_AddClients.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Persistence/Migrations/20261002174511_AddClients.cs @@ -1,5 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs index 74508d61..66b0267c 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs @@ -22,24 +22,26 @@ public ClientRepository(ServicesDataContext dbContext) public async Task UpdateAsync(Client client, CancellationToken cancellationToken) { - var existingGuardians = await DbContext.Set() - .Where(guardian => guardian.ClientId == client.Id) - .ToListAsync(cancellationToken); + await SyncContactsAsync(client.Id, client.Guardians, cancellationToken); + await SyncContactsAsync(client.Id, client.ReferenceContacts, cancellationToken); + + DbContext.Entry(client).State = EntityState.Modified; + } - var existingReferenceContacts = await DbContext.Set() - .Where(contact => contact.ClientId == client.Id) + private async Task SyncContactsAsync(Guid clientId, IReadOnlyCollection currentContacts, CancellationToken cancellationToken) where TContact : ClientContact + { + var existingContacts = await DbContext.Set() + .Where(contact => contact.ClientId == clientId) .ToListAsync(cancellationToken); - var currentGuardianIds = client.Guardians.Select(guardian => guardian.Id).ToHashSet(); - var currentReferenceContactIds = client.ReferenceContacts.Select(contact => contact.Id).ToHashSet(); - var existingGuardianIds = existingGuardians.Select(guardian => guardian.Id).ToHashSet(); - var existingReferenceContactIds = existingReferenceContacts.Select(contact => contact.Id).ToHashSet(); + var currentIds = currentContacts.Select(contact => contact.Id).ToHashSet(); + var existingIds = existingContacts.Select(contact => contact.Id).ToHashSet(); - DbContext.RemoveRange(existingGuardians.Where(guardian => !currentGuardianIds.Contains(guardian.Id))); - DbContext.RemoveRange(existingReferenceContacts.Where(contact => !currentReferenceContactIds.Contains(contact.Id))); - DbContext.Entry(client).State = EntityState.Modified; - DbContext.Set().AddRange(client.Guardians.Where(guardian => !existingGuardianIds.Contains(guardian.Id))); - DbContext.Set().AddRange(client.ReferenceContacts.Where(contact => !existingReferenceContactIds.Contains(contact.Id))); + var removedContacts = existingContacts.ExceptBy(currentIds, contact => contact.Id); + var addedContacts = currentContacts.ExceptBy(existingIds, contact => contact.Id); + + DbContext.RemoveRange(removedContacts); + DbContext.Set().AddRange(addedContacts); } public Task FindByCpfAsync(CpfNumber cpf, Guid? excludeClientId, CancellationToken cancellationToken) diff --git a/backend/services/services-service/ServicesService.PersistenceTests/ClientPersistenceTests.cs b/backend/services/services-service/ServicesService.PersistenceTests/ClientPersistenceTests.cs index ff70fce0..62ef3b6e 100644 --- a/backend/services/services-service/ServicesService.PersistenceTests/ClientPersistenceTests.cs +++ b/backend/services/services-service/ServicesService.PersistenceTests/ClientPersistenceTests.cs @@ -1,7 +1,7 @@ using Admin.Identity.Client; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; using ServicesService.Application.Abstractions; using ServicesService.Infrastructure.Persistence; using ServicesService.Infrastructure.Persistence.Interceptors; diff --git a/backend/services/services-service/ServicesService.Tests/Clients/ClientUpdateTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/ClientUpdateTests.cs index 10668c3d..95060254 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/ClientUpdateTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/ClientUpdateTests.cs @@ -1,7 +1,3 @@ -using ServicesService.Domain.Common; -using ServicesService.Domain.Entities; -using ServicesService.Domain.ValueObjects; - namespace ServicesService.Tests.Clients; public class ClientUpdateTests diff --git a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandBindingTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandBindingTests.cs index 230d7114..a05049e5 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandBindingTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandBindingTests.cs @@ -1,5 +1,5 @@ -using System.Text.Json; using ServicesService.Application.Clients.CreateClient; +using System.Text.Json; namespace ServicesService.Tests.Clients.CreateClient; diff --git a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandBirthDateBindingTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandBirthDateBindingTests.cs index 9c65daf5..4e1fe6c6 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandBirthDateBindingTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandBirthDateBindingTests.cs @@ -1,5 +1,5 @@ -using System.Text.Json; using ServicesService.Application.Clients.CreateClient; +using System.Text.Json; namespace ServicesService.Tests.Clients.CreateClient; diff --git a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandCpfBindingTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandCpfBindingTests.cs index 4b37c805..f5fb967d 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandCpfBindingTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandCpfBindingTests.cs @@ -1,5 +1,5 @@ -using System.Text.Json; using ServicesService.Application.Clients.CreateClient; +using System.Text.Json; namespace ServicesService.Tests.Clients.CreateClient; diff --git a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandEmailBindingTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandEmailBindingTests.cs index bd7be481..2e118c10 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandEmailBindingTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandEmailBindingTests.cs @@ -1,5 +1,5 @@ -using System.Text.Json; using ServicesService.Application.Clients.CreateClient; +using System.Text.Json; namespace ServicesService.Tests.Clients.CreateClient; diff --git a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandFullNameBindingTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandFullNameBindingTests.cs index 1e8cf8e5..9be33792 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandFullNameBindingTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandFullNameBindingTests.cs @@ -1,5 +1,5 @@ -using System.Text.Json; using ServicesService.Application.Clients.CreateClient; +using System.Text.Json; namespace ServicesService.Tests.Clients.CreateClient; diff --git a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandNotesBindingTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandNotesBindingTests.cs index 2cc46627..1da7f2a8 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandNotesBindingTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandNotesBindingTests.cs @@ -1,5 +1,5 @@ -using System.Text.Json; using ServicesService.Application.Clients.CreateClient; +using System.Text.Json; namespace ServicesService.Tests.Clients.CreateClient; diff --git a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandPhoneBindingTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandPhoneBindingTests.cs index c8efe225..ea491fd8 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandPhoneBindingTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandPhoneBindingTests.cs @@ -1,5 +1,5 @@ -using System.Text.Json; using ServicesService.Application.Clients.CreateClient; +using System.Text.Json; namespace ServicesService.Tests.Clients.CreateClient; diff --git a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandPurposesBindingTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandPurposesBindingTests.cs index 5a352865..acdba6dc 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandPurposesBindingTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/CreateClient/CreateClientCommandPurposesBindingTests.cs @@ -1,6 +1,6 @@ -using System.Text.Json; using ServicesService.Application.Clients; using ServicesService.Application.Clients.CreateClient; +using System.Text.Json; namespace ServicesService.Tests.Clients.CreateClient; diff --git a/backend/services/services-service/ServicesService.Tests/Clients/GetClientById/GetClientByIdQueryHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/GetClientById/GetClientByIdQueryHandlerTests.cs index f60003d9..65160c50 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/GetClientById/GetClientByIdQueryHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/GetClientById/GetClientByIdQueryHandlerTests.cs @@ -1,7 +1,6 @@ using Admin.SharedKernel; using ServicesService.Application.Abstractions; using ServicesService.Application.Clients.GetClientById; -using ServicesService.Domain.Entities; namespace ServicesService.Tests.Clients.GetClientById; diff --git a/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandBindingTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandBindingTests.cs index ac9aceb1..66d53000 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandBindingTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandBindingTests.cs @@ -1,5 +1,5 @@ -using System.Text.Json; using ServicesService.Application.Clients.UpdateClient; +using System.Text.Json; namespace ServicesService.Tests.Clients.UpdateClient; diff --git a/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandHandlerTests.cs index c20fccc0..3c735d52 100644 --- a/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandHandlerTests.cs +++ b/backend/services/services-service/ServicesService.Tests/Clients/UpdateClient/UpdateClientCommandHandlerTests.cs @@ -1,8 +1,6 @@ using Admin.SharedKernel; using ServicesService.Application.Abstractions; using ServicesService.Application.Clients.UpdateClient; -using ServicesService.Domain.Entities; -using ServicesService.Domain.ValueObjects; namespace ServicesService.Tests.Clients.UpdateClient; diff --git a/backend/services/services-service/ServicesService.Tests/WireJson.cs b/backend/services/services-service/ServicesService.Tests/WireJson.cs index e2525324..390a6d3a 100644 --- a/backend/services/services-service/ServicesService.Tests/WireJson.cs +++ b/backend/services/services-service/ServicesService.Tests/WireJson.cs @@ -1,6 +1,6 @@ -using System.Text.Json; using Admin.SharedKernel; using ServicesService.Tests.Clients; +using System.Text.Json; namespace ServicesService.Tests; diff --git a/backend/shared/Admin.Logging.Tests/ReadableConsoleTests.cs b/backend/shared/Admin.Logging.Tests/ReadableConsoleTests.cs index 464ac634..38174320 100644 --- a/backend/shared/Admin.Logging.Tests/ReadableConsoleTests.cs +++ b/backend/shared/Admin.Logging.Tests/ReadableConsoleTests.cs @@ -1,7 +1,7 @@ -using System.Globalization; using Microsoft.Extensions.Logging; using Serilog; using Serilog.Extensions.Logging; +using System.Globalization; namespace Admin.Logging.Tests; diff --git a/backend/shared/Admin.Logging.Tests/ReadableLoggingExtensionsTests.cs b/backend/shared/Admin.Logging.Tests/ReadableLoggingExtensionsTests.cs index 81e23512..94fef35c 100644 --- a/backend/shared/Admin.Logging.Tests/ReadableLoggingExtensionsTests.cs +++ b/backend/shared/Admin.Logging.Tests/ReadableLoggingExtensionsTests.cs @@ -1,4 +1,3 @@ -using System.Globalization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -6,6 +5,7 @@ using NSubstitute; using Serilog; using Serilog.Sinks.OpenTelemetry; +using System.Globalization; namespace Admin.Logging.Tests; diff --git a/backend/shared/Admin.Logging/ReadableConsole.cs b/backend/shared/Admin.Logging/ReadableConsole.cs index 6eb859dd..49c804b2 100644 --- a/backend/shared/Admin.Logging/ReadableConsole.cs +++ b/backend/shared/Admin.Logging/ReadableConsole.cs @@ -1,7 +1,7 @@ -using System.Globalization; using Serilog.Formatting; using Serilog.Templates; using Serilog.Templates.Themes; +using System.Globalization; namespace Admin.Logging; diff --git a/backend/shared/Admin.Logging/ReadableLoggingExtensions.cs b/backend/shared/Admin.Logging/ReadableLoggingExtensions.cs index 27d4f11a..f49fa501 100644 --- a/backend/shared/Admin.Logging/ReadableLoggingExtensions.cs +++ b/backend/shared/Admin.Logging/ReadableLoggingExtensions.cs @@ -1,10 +1,10 @@ -using System.Globalization; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Serilog; using Serilog.Events; using Serilog.Sinks.OpenTelemetry; +using System.Globalization; namespace Admin.Logging; diff --git a/backend/shared/Admin.SharedKernel.AspNetCore/ApiProblemDetailsFactory.cs b/backend/shared/Admin.SharedKernel.AspNetCore/ApiProblemDetailsFactory.cs index 07397dca..0542fe6e 100644 --- a/backend/shared/Admin.SharedKernel.AspNetCore/ApiProblemDetailsFactory.cs +++ b/backend/shared/Admin.SharedKernel.AspNetCore/ApiProblemDetailsFactory.cs @@ -1,6 +1,6 @@ -using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Primitives; +using System.Text.Json; namespace Admin.SharedKernel.AspNetCore; diff --git a/backend/shared/Admin.SharedKernel.AspNetCore/GenericExceptionHandler.cs b/backend/shared/Admin.SharedKernel.AspNetCore/GenericExceptionHandler.cs index 160f358d..ad5cdfa0 100644 --- a/backend/shared/Admin.SharedKernel.AspNetCore/GenericExceptionHandler.cs +++ b/backend/shared/Admin.SharedKernel.AspNetCore/GenericExceptionHandler.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Admin.SharedKernel.AspNetCore; diff --git a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/DateValueObjectConverter.cs b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/DateValueObjectConverter.cs index 55967d50..4298674a 100644 --- a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/DateValueObjectConverter.cs +++ b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/DateValueObjectConverter.cs @@ -1,6 +1,6 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using System.Linq.Expressions; using System.Reflection; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Admin.SharedKernel.EntityFrameworkCore; diff --git a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/ModelBuilderExtensions.cs b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/ModelBuilderExtensions.cs index 2577ffc2..0a826781 100644 --- a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/ModelBuilderExtensions.cs +++ b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/ModelBuilderExtensions.cs @@ -1,6 +1,6 @@ +using Microsoft.EntityFrameworkCore; using System.Linq.Expressions; using System.Reflection; -using Microsoft.EntityFrameworkCore; namespace Admin.SharedKernel.EntityFrameworkCore; diff --git a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs index e5a2404c..59d99759 100644 --- a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs +++ b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/RepositoryBase.cs @@ -1,5 +1,5 @@ -using System.Linq.Expressions; using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; namespace Admin.SharedKernel.EntityFrameworkCore; diff --git a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/StringValueObjectConverter.cs b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/StringValueObjectConverter.cs index d1d1d644..1a97e858 100644 --- a/backend/shared/Admin.SharedKernel.EntityFrameworkCore/StringValueObjectConverter.cs +++ b/backend/shared/Admin.SharedKernel.EntityFrameworkCore/StringValueObjectConverter.cs @@ -1,6 +1,6 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using System.Linq.Expressions; using System.Reflection; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace Admin.SharedKernel.EntityFrameworkCore; diff --git a/backend/shared/Admin.SharedKernel.Tests/GenericExceptionHandlerTests.cs b/backend/shared/Admin.SharedKernel.Tests/GenericExceptionHandlerTests.cs index 12d2729e..286dd4ab 100644 --- a/backend/shared/Admin.SharedKernel.Tests/GenericExceptionHandlerTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/GenericExceptionHandlerTests.cs @@ -1,7 +1,7 @@ -using System.Text.Json; using Admin.SharedKernel.AspNetCore; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging.Abstractions; +using System.Text.Json; namespace Admin.SharedKernel.Tests; diff --git a/backend/shared/Admin.SharedKernel.Tests/ModelStateErrorMapperTests.cs b/backend/shared/Admin.SharedKernel.Tests/ModelStateErrorMapperTests.cs index 493d54bf..39601106 100644 --- a/backend/shared/Admin.SharedKernel.Tests/ModelStateErrorMapperTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/ModelStateErrorMapperTests.cs @@ -1,6 +1,6 @@ -using System.Text.Json; using Admin.SharedKernel.AspNetCore; using Microsoft.AspNetCore.Mvc.ModelBinding; +using System.Text.Json; namespace Admin.SharedKernel.Tests; diff --git a/backend/shared/Admin.SharedKernel.Tests/MvcBuilderExtensionsTests.cs b/backend/shared/Admin.SharedKernel.Tests/MvcBuilderExtensionsTests.cs index a312d5eb..fce8e22b 100644 --- a/backend/shared/Admin.SharedKernel.Tests/MvcBuilderExtensionsTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/MvcBuilderExtensionsTests.cs @@ -1,4 +1,3 @@ -using System.Text.Json; using Admin.SharedKernel.AspNetCore; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -6,6 +5,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using System.Text.Json; using HttpJsonOptions = Microsoft.AspNetCore.Http.Json.JsonOptions; namespace Admin.SharedKernel.Tests; diff --git a/backend/shared/Admin.SharedKernel.Tests/OpenApiOptionsExtensionsTests.cs b/backend/shared/Admin.SharedKernel.Tests/OpenApiOptionsExtensionsTests.cs index d2221677..7fe978ac 100644 --- a/backend/shared/Admin.SharedKernel.Tests/OpenApiOptionsExtensionsTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/OpenApiOptionsExtensionsTests.cs @@ -1,6 +1,6 @@ -using System.Text.Json; using Admin.SharedKernel.AspNetCore; using Microsoft.AspNetCore.OpenApi; +using System.Text.Json; namespace Admin.SharedKernel.Tests; diff --git a/backend/shared/Admin.SharedKernel.Tests/ServiceCollectionExtensionsTests.cs b/backend/shared/Admin.SharedKernel.Tests/ServiceCollectionExtensionsTests.cs index 56b0a3a2..0aac0574 100644 --- a/backend/shared/Admin.SharedKernel.Tests/ServiceCollectionExtensionsTests.cs +++ b/backend/shared/Admin.SharedKernel.Tests/ServiceCollectionExtensionsTests.cs @@ -1,5 +1,5 @@ -using System.Reflection; using Microsoft.Extensions.DependencyInjection; +using System.Reflection; namespace Admin.SharedKernel.Tests; diff --git a/backend/shared/Admin.SharedKernel.ValueObjects/ValueObjectContract.cs b/backend/shared/Admin.SharedKernel.ValueObjects/ValueObjectContract.cs index 1e81d0dc..1f366903 100644 --- a/backend/shared/Admin.SharedKernel.ValueObjects/ValueObjectContract.cs +++ b/backend/shared/Admin.SharedKernel.ValueObjects/ValueObjectContract.cs @@ -1,5 +1,3 @@ -using System.Reflection; - namespace Admin.SharedKernel.ValueObjects; internal static class ValueObjectContract diff --git a/backend/shared/Admin.SharedKernel/Dispatcher.cs b/backend/shared/Admin.SharedKernel/Dispatcher.cs index 611faae9..c2eb4627 100644 --- a/backend/shared/Admin.SharedKernel/Dispatcher.cs +++ b/backend/shared/Admin.SharedKernel/Dispatcher.cs @@ -1,4 +1,3 @@ -using System.Reflection; using FluentValidation; using Microsoft.Extensions.DependencyInjection; diff --git a/backend/shared/Admin.SharedKernel/ServiceCollectionExtensions.cs b/backend/shared/Admin.SharedKernel/ServiceCollectionExtensions.cs index 4f361e88..d7638a4f 100644 --- a/backend/shared/Admin.SharedKernel/ServiceCollectionExtensions.cs +++ b/backend/shared/Admin.SharedKernel/ServiceCollectionExtensions.cs @@ -1,5 +1,5 @@ -using System.Reflection; using Microsoft.Extensions.DependencyInjection; +using System.Reflection; namespace Admin.SharedKernel;