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/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/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/docs/ARCHITECTURE.md b/backend/docs/ARCHITECTURE.md index 7fee2e8a..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)). @@ -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/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/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.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/Abstractions/IAppointmentRepository.cs b/backend/services/services-service/ServicesService.Application/Abstractions/IAppointmentRepository.cs new file mode 100644 index 00000000..9e6f1877 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Abstractions/IAppointmentRepository.cs @@ -0,0 +1,6 @@ +namespace ServicesService.Application.Abstractions; + +public interface IAppointmentRepository +{ + Task HasUpcomingAppointmentsAsync(Guid clientId, 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/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..befcd682 --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/DeactivateClient/DeactivateClientCommandHandler.cs @@ -0,0 +1,61 @@ +using Admin.SharedKernel; +using ServicesService.Application.Abstractions; + +namespace ServicesService.Application.Clients.DeactivateClient; + +public sealed class DeactivateClientCommandHandler( + IClientRepository clientRepository, + IAppointmentRepository appointmentRepository, + IUnitOfWork unitOfWork) : 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.UpdateAsync(client, cancellationToken); + + var saveResult = await unitOfWork.SaveChangesAsync(cancellationToken); + if (saveResult.IsFailure) + { + 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 hasUpcomingAppointments = await appointmentRepository.HasUpcomingAppointmentsAsync(client.Id, cancellationToken); + if (!hasUpcomingAppointments) + { + 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..ea38831a --- /dev/null +++ b/backend/services/services-service/ServicesService.Application/Clients/ReactivateClient/ReactivateClientCommandHandler.cs @@ -0,0 +1,105 @@ +using Admin.SharedKernel; +using ServicesService.Application.Abstractions; + +namespace ServicesService.Application.Clients.ReactivateClient; + +public sealed class ReactivateClientCommandHandler( + IClientRepository clientRepository, + IUnitOfWork unitOfWork) : 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 cpfConflict = await FindCpfConflictAsync(client, cancellationToken); + if (cpfConflict is { } cpfError) + { + return Result.Failure(cpfError); + } + + 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.UpdateAsync(client, cancellationToken); + + var saveResult = await unitOfWork.SaveChangesAsync(cancellationToken); + if (saveResult.IsFailure) + { + return Result.Failure(Error.Conflict( + "Client.SaveFailed", + "Não foi possível salvar a pessoa. Tente novamente.")); + } + + 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) + { + 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: 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/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.Application/Clients/UpdateClient/UpdateClientCommandHandler.cs b/backend/services/services-service/ServicesService.Application/Clients/UpdateClient/UpdateClientCommandHandler.cs index 1f0e8c56..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,16 +1,12 @@ using Admin.SharedKernel; -using Microsoft.Extensions.Logging; using ServicesService.Application.Abstractions; -using ServicesService.Domain.Common; -using ServicesService.Domain.Entities; 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 +42,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.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.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/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/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 8d451b54..66b0267c 100644 --- a/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/ClientRepository.cs @@ -22,19 +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); - DbContext.RemoveRange(existingGuardians); - DbContext.RemoveRange(existingReferenceContacts); - DbContext.Entry(client).State = EntityState.Modified; - DbContext.Set().AddRange(client.Guardians); - DbContext.Set().AddRange(client.ReferenceContacts); + var currentIds = currentContacts.Select(contact => contact.Id).ToHashSet(); + var existingIds = existingContacts.Select(contact => contact.Id).ToHashSet(); + + 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.Infrastructure/Repositories/PendingAppointmentRepository.cs b/backend/services/services-service/ServicesService.Infrastructure/Repositories/PendingAppointmentRepository.cs new file mode 100644 index 00000000..ab087f4b --- /dev/null +++ b/backend/services/services-service/ServicesService.Infrastructure/Repositories/PendingAppointmentRepository.cs @@ -0,0 +1,11 @@ +using ServicesService.Application.Abstractions; + +namespace ServicesService.Infrastructure.Repositories; + +public class PendingAppointmentRepository : IAppointmentRepository +{ + public Task HasUpcomingAppointmentsAsync(Guid clientId, 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..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; @@ -583,6 +583,94 @@ public async Task Update_ReplacesEveryContactInOneSave_AndSoftDeletesThePrevious } } + private static async Task ChangeStatusAndUpdate( + 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.UpdateAsync(loaded, CancellationToken.None); + await context.SaveChangesAsync(CancellationToken.None); + } + + [Fact] + public async Task Update_AfterAStatusChange_KeepsTheSameContactRows() + { + 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 ChangeStatusAndUpdate(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 Update_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 ChangeStatusAndUpdate(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 ChangeStatusAndUpdate(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.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/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/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/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/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/DeactivateClient/DeactivateClientCommandHandlerTests.cs b/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs new file mode 100644 index 00000000..c6aee97c --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/DeactivateClient/DeactivateClientCommandHandlerTests.cs @@ -0,0 +1,140 @@ +using Admin.SharedKernel; +using ServicesService.Application.Abstractions; +using ServicesService.Application.Clients.DeactivateClient; + +namespace ServicesService.Tests.Clients.DeactivateClient; + +public class DeactivateClientCommandHandlerTests +{ + private readonly IClientRepository _repository = Substitute.For(); + private readonly IAppointmentRepository _appointments = Substitute.For(); + private readonly IUnitOfWork _unitOfWork = Substitute.For(); + private readonly Client _client = ClientWithContacts(); + + public DeactivateClientCommandHandlerTests() + { + _repository.GetByIdAsync(_client.Id, Arg.Any()).Returns(Task.FromResult(_client)); + _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); + + 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().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).UpdateAsync(_client, Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(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_AsksOnceForTheUpcomingAppointmentsOfTheClient() + { + await Handler().Handle(new DeactivateClientCommand(_client.Id), CancellationToken.None); + + await _appointments.Received(1).HasUpcomingAppointmentsAsync(_client.Id, Arg.Any()); + } + + [Fact] + public async Task Handle_WithUpcomingAppointments_ReturnsAConflictAndKeepsTheClientActive() + { + _appointments.HasUpcomingAppointmentsAsync(_client.Id, 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().HasUpcomingAppointmentsAsync(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().HasUpcomingAppointmentsAsync(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..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; @@ -44,7 +43,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..38675984 --- /dev/null +++ b/backend/services/services-service/ServicesService.Tests/Clients/ReactivateClient/ReactivateClientCommandHandlerTests.cs @@ -0,0 +1,220 @@ +using Admin.SharedKernel; +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 Client _client = InactiveClientWithContacts(); + + public ReactivateClientCommandHandlerTests() + { + _repository.GetByIdAsync(_client.Id, Arg.Any()).Returns(Task.FromResult(_client)); + _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); + + private static Client InactiveClientWithContacts(EmailAddress? email = null) + { + var client = Client.Create( + 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; + client.Inactivate(); + return client; + } + + private async Task AssertNothingWasPersisted() + { + await _repository.DidNotReceive().UpdateAsync(Arg.Any(), Arg.Any()); + _repository.DidNotReceive().Add(Arg.Any()); + await _unitOfWork.DidNotReceive().SaveChangesAsync(Arg.Any()); + } + + [Fact] + public async Task Handle_WithNoConflict_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).UpdateAsync(_client, Arg.Any()); + await _unitOfWork.Received(1).SaveChangesAsync(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_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(); + _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()); + await _repository.DidNotReceive().FindByCpfAsync(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.Cpf.Should().Be(ClientTestData.ValidCpfDigits); + 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_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() + { + 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().FindByCpfAsync(Arg.Any(), Arg.Any(), Arg.Any()); + 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().FindByCpfAsync(Arg.Any(), Arg.Any(), Arg.Any()); + 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/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 6f96a998..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,9 +1,6 @@ using Admin.SharedKernel; -using Microsoft.Extensions.Logging; using ServicesService.Application.Abstractions; using ServicesService.Application.Clients.UpdateClient; -using ServicesService.Domain.Entities; -using ServicesService.Domain.ValueObjects; namespace ServicesService.Tests.Clients.UpdateClient; @@ -13,7 +10,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 +24,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() { @@ -79,7 +75,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/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; 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 new file mode 100644 index 00000000..17a82f05 --- /dev/null +++ b/docs/adr/0062-clients-deactivate-and-reactivate.md @@ -0,0 +1,130 @@ +# 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 saved through the same `UpdateAsync` as the edit, +whose contacts [ADR 0056](0056-clients-edit-replaces-contact-composition.md) makes it 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. + +**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 +`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, 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`, +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. + +**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 +[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()`. +- `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, 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. +- **`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, + 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. 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.