Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0c69b4f
feat(application): add IAuthenticatedRequest<TResponse> interface
evans-costa Mar 31, 2026
57edd3f
test(application): add AuthorizationBehavior tests (red)
evans-costa Mar 31, 2026
1a28e49
feat(application): add AuthorizationBehavior pipeline behavior
evans-costa Mar 31, 2026
e6f3224
feat(application): register AuthorizationBehavior in MediatR pipeline
evans-costa Mar 31, 2026
554c575
refactor(clients): migrate GetClientsQuery to IAuthenticatedRequest
evans-costa Mar 31, 2026
affc81d
refactor(clients): migrate GetClientByIdQuery to IAuthenticatedRequest
evans-costa Mar 31, 2026
8812db9
refactor(clients): migrate RegisterClientCommand to IAuthenticatedReq…
evans-costa Mar 31, 2026
94bf656
refactor(clients): migrate UpdateClientCommand to IAuthenticatedRequest
evans-costa Mar 31, 2026
c9a044a
refactor(clients): migrate DeleteClientCommand to IAuthenticatedRequest
evans-costa Mar 31, 2026
13b8055
refactor(users): migrate GetCurrentUserQuery to IAuthenticatedRequest
evans-costa Mar 31, 2026
715b0ba
test(clients): adapt GetClientsQueryHandlerTests to IAuthenticatedReq…
evans-costa Mar 31, 2026
484ec14
test(clients): adapt GetClientByIdQueryHandlerTests to IAuthenticated…
evans-costa Mar 31, 2026
3d40b4d
test(clients): adapt RegisterClientCommandHandlerTests to IAuthentica…
evans-costa Mar 31, 2026
e9651cd
test(clients): adapt UpdateClientCommandHandlerTests to IAuthenticate…
evans-costa Mar 31, 2026
1c6e0e4
test(clients): adapt DeleteClientCommandHandlerTests to IAuthenticate…
evans-costa Mar 31, 2026
2c5c8d0
test(users): adapt GetCurrentUserQueryHandlerTests to IAuthenticatedR…
evans-costa Mar 31, 2026
c199a42
refactor(validation): streamline error handling and remove unused met…
evans-costa Mar 31, 2026
dd1d5ce
fix(localization): correct invalid email/password error message
evans-costa Mar 31, 2026
68af860
refactor(Program): configure JSON options for camelCase property naming
evans-costa Mar 31, 2026
2865f8f
refactor(domain): replace generic IRepository<T> with typed role inte…
evans-costa Mar 31, 2026
1004684
refactor(infrastructure): replace Repository<T> base class with entit…
evans-costa Mar 31, 2026
cadcf44
refactor(application): inject typed repository interfaces into comman…
evans-costa Mar 31, 2026
4dc07aa
test(application): update mocks to use typed repository interfaces
evans-costa Mar 31, 2026
37cf47a
test(infrastructure): replace Repository<User> helper with UserReposi…
evans-costa Mar 31, 2026
93e848f
style: fix format erros with dotnet format
evans-costa Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions src/Voltiq.API/Program.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using Asp.Versioning;
using Microsoft.OpenApi;
using Serilog;
Expand All @@ -17,7 +18,13 @@
builder.Services.AddProblemDetails();
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();

builder.Services.AddControllers();
builder.Services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
options.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;
});

builder.Services.AddRouting(options => options.LowercaseUrls = true);

builder.Services.AddApiVersioning(options =>
Expand Down Expand Up @@ -49,11 +56,11 @@
"""
Voltiq é uma aplicação para gerir orçamentos, clientes e materiais para
profissionais autônomos, principalmente eletricistas, oferecendo uma maneira
prática e eficiente de gerir seus clientes e orçamentos.
prática e eficiente de gerir seus serviços.
""",
Contact = new OpenApiContact
{
Name = "Team Voltiq",
Name = "Time Voltiq",
Email = "suporte@voltiq.com.br"
}
};
Expand Down Expand Up @@ -112,8 +119,10 @@ prática e eficiente de gerir seus clientes e orçamentos.

app.UseHttpsRedirection();
app.UseCors();

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers().RequireAuthorization();

await app.RunAsync();
29 changes: 29 additions & 0 deletions src/Voltiq.Application/Common/Behaviors/AuthorizationBehavior.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
using ErrorOr;
using MediatR;
using Voltiq.Application.Common.Interfaces;
using Voltiq.Exceptions.Resources;

namespace Voltiq.Application.Common.Behaviors;

public sealed class AuthorizationBehavior<TRequest, TResponse>(ICurrentUserService currentUserService)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IAuthenticatedRequest<TResponse>
where TResponse : IErrorOr
{
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
var userId = currentUserService.UserId;

if (userId == Guid.Empty)
{
var error = Error.Unauthorized(description: ResourceErrorMessages.TITULO_NAO_AUTORIZADO);
return (dynamic)error;
}

request.UserId = userId;
return await next(cancellationToken);
}
}
32 changes: 6 additions & 26 deletions src/Voltiq.Application/Common/Behaviors/ValidationBehavior.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
using System.Reflection;
using ErrorOr;
using FluentValidation;
using MediatR;

namespace Voltiq.Application.Common.Behaviors;

public sealed class ValidationBehavior<TRequest, TResponse>(IEnumerable<IValidator<TRequest>> validators)
public sealed class ValidationBehavior<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
where TResponse : IErrorOr
Expand All @@ -20,36 +20,16 @@ public async Task<TResponse> Handle(

var context = new ValidationContext<TRequest>(request);

var failures = validators
var errors = validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.Where(f => f is not null)
.Select(f => Error.Validation(f.PropertyName, f.ErrorMessage))
.ToList();

if (failures.Count == 0)
if (errors.Count == 0)
return await next(cancellationToken);

var errors = failures
.Select(f => Error.Validation(code: f.PropertyName, description: f.ErrorMessage))
.ToList();

return CreateFailure(errors);
}

private static TResponse CreateFailure(List<Error> errors)
{
var responseType = typeof(TResponse);

if (responseType.IsGenericType && responseType.GetGenericTypeDefinition() == typeof(ErrorOr<>))
{
var typeArg = responseType.GetGenericArguments()[0];
var implicitOp = typeof(ErrorOr<>)
.MakeGenericType(typeArg)
.GetMethod("op_Implicit", BindingFlags.Static | BindingFlags.Public, [typeof(List<Error>)])!;

return (TResponse)implicitOp.Invoke(null, [errors])!;
}

throw new InvalidOperationException($"Unexpected TResponse type: {responseType}");
return (dynamic)errors;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
using MediatR;

namespace Voltiq.Application.Common.Interfaces;

public interface IAuthenticatedRequest<TResponse> : IRequest<TResponse>

Check warning on line 5 in src/Voltiq.Application/Common/Interfaces/IAuthenticatedRequest.cs

View workflow job for this annotation

GitHub Actions / Build and Unit Tests

Add the 'out' keyword to parameter 'TResponse' to make it 'covariant'.
{
Guid UserId { get; set; }
}
1 change: 1 addition & 0 deletions src/Voltiq.Application/DependencyInjection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public static void AddApplication(this IServiceCollection services,
throw new InvalidOperationException("MediatR license key is not configured.");
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehavior<,>));
});

services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@
using Voltiq.Application.Common.Interfaces;
using Voltiq.Domain.Entities;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories;
using Voltiq.Domain.Interfaces.Repositories.RefreshToken;
using Voltiq.Domain.Interfaces.Repositories.User;
using Voltiq.Domain.ValueObjects;
using Voltiq.Exceptions.Resources;

namespace Voltiq.Application.Features.Auth.Commands.Login;

public sealed class LoginCommandHandler(
IUserRepository userRepository,
IUserReadOnlyRepository userRepository,
IPasswordHasher passwordHasher,
ITokenService tokenService,
IRefreshTokenRepository refreshTokenRepository,
IRefreshTokenWriteOnlyRepository refreshTokenRepository,
IUnitOfWork unitOfWork)
: IRequestHandler<LoginCommand, ErrorOr<LoginResponse>>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,23 @@
using Voltiq.Application.Common.Interfaces;
using Voltiq.Domain.Entities;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories;
using Voltiq.Domain.Interfaces.Repositories.RefreshToken;
using Voltiq.Domain.Interfaces.Repositories.User;
using Voltiq.Exceptions.Resources;

namespace Voltiq.Application.Features.Auth.Commands.Refresh;

public sealed class RefreshTokenCommandHandler(
IRefreshTokenRepository refreshTokenRepository,
IUserRepository userRepository,
IRefreshTokenReadOnlyRepository refreshTokenReadOnlyRepository,
IRefreshTokenWriteOnlyRepository refreshTokenWriteOnlyRepository,
IUserReadOnlyRepository userRepository,
ITokenService tokenService,
IUnitOfWork unitOfWork)
: IRequestHandler<RefreshTokenCommand, ErrorOr<AuthResponse>>
{
public async Task<ErrorOr<AuthResponse>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
{
var refreshToken = await refreshTokenRepository.GetByTokenAsync(request.RefreshToken, cancellationToken);
var refreshToken = await refreshTokenReadOnlyRepository.GetByTokenAsync(request.RefreshToken, cancellationToken);

if (refreshToken is null)
return Error.Unauthorized(description: ResourceErrorMessages.REFRESH_TOKEN_NAO_ENCONTRADO);
Expand All @@ -40,7 +41,7 @@ public async Task<ErrorOr<AuthResponse>> Handle(RefreshTokenCommand request, Can
var newRawRefreshToken = tokenService.GenerateRefreshToken();

var newRefreshToken = RefreshToken.Create(newRawRefreshToken, user.Id, expiresInDays: 7);
await refreshTokenRepository.AddAsync(newRefreshToken, cancellationToken);
await refreshTokenWriteOnlyRepository.AddAsync(newRefreshToken, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

return new AuthResponse(newAccessToken, newRawRefreshToken);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using ErrorOr;
using MediatR;
using Voltiq.Application.Common.Interfaces;

namespace Voltiq.Application.Features.Clients.Commands.DeleteClient;

public sealed record DeleteClientCommand(Guid Id) : IRequest<ErrorOr<Deleted>>;
public sealed record DeleteClientCommand(Guid Id) : IAuthenticatedRequest<ErrorOr<Deleted>>
{
public Guid UserId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,28 +1,21 @@
using ErrorOr;
using MediatR;
using Voltiq.Application.Common.Interfaces;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories.Client;
using Voltiq.Exceptions.Resources;

namespace Voltiq.Application.Features.Clients.Commands.DeleteClient;

public sealed class DeleteClientCommandHandler(
IClientRepository clientRepository,
IUnitOfWork unitOfWork,
ICurrentUserService currentUserService)
IClientUpdateOnlyRepository clientRepository,
IUnitOfWork unitOfWork)
: IRequestHandler<DeleteClientCommand, ErrorOr<Deleted>>
{
public async Task<ErrorOr<Deleted>> Handle(DeleteClientCommand request,
CancellationToken cancellationToken)
{
var userId = currentUserService.UserId;

if (userId == Guid.Empty)
return Error.Unauthorized(description: ResourceErrorMessages.TITULO_NAO_AUTORIZADO);

var client =
await clientRepository.GetByIdAndUserIdAsync(request.Id, userId, cancellationToken);
await clientRepository.GetByIdAndUserIdAsync(request.Id, request.UserId, cancellationToken);

if (client is null)
return Error.NotFound(description: ResourceErrorMessages.CLIENTE_NAO_ENCONTRADO);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using ErrorOr;
using MediatR;
using Voltiq.Application.Common.Interfaces;

namespace Voltiq.Application.Features.Clients.Commands.RegisterClient;

Expand All @@ -11,4 +11,7 @@ public sealed record RegisterClientCommand(
string Number,
string City,
string State,
string ZipCode) : IRequest<ErrorOr<ClientResponse>>;
string ZipCode) : IAuthenticatedRequest<ErrorOr<ClientResponse>>
{
public Guid UserId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using ErrorOr;
using MediatR;
using Voltiq.Application.Common.Interfaces;
using Voltiq.Application.Mappings.Clients;
using Voltiq.Domain.Entities;
using Voltiq.Domain.Interfaces;
Expand All @@ -11,32 +10,27 @@
namespace Voltiq.Application.Features.Clients.Commands.RegisterClient;

public sealed class RegisterClientCommandHandler(
IClientRepository clientRepository,
IUnitOfWork unitOfWork,
ICurrentUserService currentUserService)
IClientReadOnlyRepository clientReadOnlyRepository,
IClientWriteOnlyRepository clientWriteOnlyRepository,
IUnitOfWork unitOfWork)
: IRequestHandler<RegisterClientCommand, ErrorOr<ClientResponse>>
{
public async Task<ErrorOr<ClientResponse>> Handle(RegisterClientCommand request,
CancellationToken cancellationToken)
{
var userId = currentUserService.UserId;

if (userId == Guid.Empty)
return Error.Unauthorized(description: ResourceErrorMessages.TITULO_NAO_AUTORIZADO);

var email = Email.Create(request.Email).Value;

var emailExists = await clientRepository.ExistsWithEmailForUserAsync(
email, userId, cancellationToken: cancellationToken);
var emailExists = await clientReadOnlyRepository.ExistsWithEmailForUserAsync(
email, request.UserId, cancellationToken: cancellationToken);

if (emailExists)
return Error.Conflict(description: ResourceErrorMessages.CLIENTE_EMAIL_JA_CADASTRADO);

var address = Address.Create(request.Street, request.Number, request.City, request.State,
request.ZipCode);
var client = Client.Register(userId, request.Name, request.Phone, email, address);
var client = Client.Register(request.UserId, request.Name, request.Phone, email, address);

await clientRepository.AddAsync(client, cancellationToken);
await clientWriteOnlyRepository.AddAsync(client, cancellationToken);
await unitOfWork.SaveChangesAsync(cancellationToken);

return client.ToResponse();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using ErrorOr;
using MediatR;
using Voltiq.Application.Common.Interfaces;

namespace Voltiq.Application.Features.Clients.Commands.UpdateClient;

Expand All @@ -12,4 +12,7 @@ public sealed record UpdateClientCommand(
string Number,
string City,
string State,
string ZipCode) : IRequest<ErrorOr<Updated>>;
string ZipCode) : IAuthenticatedRequest<ErrorOr<Updated>>
{
public Guid UserId { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using ErrorOr;
using MediatR;
using Voltiq.Application.Common.Interfaces;
using Voltiq.Domain.Interfaces;
using Voltiq.Domain.Interfaces.Repositories.Client;
using Voltiq.Domain.ValueObjects;
Expand All @@ -9,29 +8,24 @@
namespace Voltiq.Application.Features.Clients.Commands.UpdateClient;

public sealed class UpdateClientCommandHandler(
IClientRepository clientRepository,
IUnitOfWork unitOfWork,
ICurrentUserService currentUserService)
IClientReadOnlyRepository clientReadOnlyRepository,
IClientUpdateOnlyRepository clientUpdateOnlyRepository,
IUnitOfWork unitOfWork)
: IRequestHandler<UpdateClientCommand, ErrorOr<Updated>>
{
public async Task<ErrorOr<Updated>> Handle(UpdateClientCommand request,
CancellationToken cancellationToken)
{
var userId = currentUserService.UserId;

if (userId == Guid.Empty)
return Error.Unauthorized(description: ResourceErrorMessages.TITULO_NAO_AUTORIZADO);

var client =
await clientRepository.GetByIdAndUserIdAsync(request.Id, userId, cancellationToken);
await clientUpdateOnlyRepository.GetByIdAndUserIdAsync(request.Id, request.UserId, cancellationToken);

if (client is null)
return Error.NotFound(description: ResourceErrorMessages.CLIENTE_NAO_ENCONTRADO);

var email = Email.Create(request.Email).Value;

var emailExists = await clientRepository.ExistsWithEmailForUserAsync(
email, userId, request.Id, cancellationToken);
var emailExists = await clientReadOnlyRepository.ExistsWithEmailForUserAsync(
email, request.UserId, request.Id, cancellationToken);

if (emailExists)
return Error.Conflict(description: ResourceErrorMessages.CLIENTE_EMAIL_JA_CADASTRADO);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using ErrorOr;
using MediatR;
using Voltiq.Application.Common.Interfaces;

namespace Voltiq.Application.Features.Clients.Queries.GetClientById;

public sealed record GetClientByIdQuery(Guid Id) : IRequest<ErrorOr<ClientResponse>>;
public sealed record GetClientByIdQuery(Guid Id) : IAuthenticatedRequest<ErrorOr<ClientResponse>>
{
public Guid UserId { get; set; }
}
Loading
Loading