feat(api): UDT-004 dominio + repositorio + application roles (tdd)
- Migraciones V003 (tabla Rol + 8 seeds canonicos) y V004 (drop CK + FK Usuario.Rol) - Dominio: Rol entity + 3 excepciones (RolNotFound/AlreadyExists/InUse) - Infraestructura: RolRepository (Dapper) con List/Get/ExistsActive/Add/Update/HasActiveUsuarios - Application: CRUD queries y commands (List, Get, Create, Update, Deactivate) + validators (codigo regex ^[a-z][a-z0-9_]*$) - Validator UDT-003: whitelist alineada a codigos canonicos (full IRolRepository lookup diferido a Phase 5.1) - Tests: 169 application + 15 api (todos verdes). Respawn configurado para re-seedear Rol canonical post-reset. - Estricto TDD: RED/GREEN/TRIANGULATE en todos los handlers nuevos.
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Application.Abstractions.Persistence;
|
||||
|
||||
public interface IRolRepository
|
||||
{
|
||||
Task<IReadOnlyList<Rol>> ListAsync(CancellationToken ct = default);
|
||||
Task<Rol?> GetByCodigoAsync(string codigo, CancellationToken ct = default);
|
||||
Task<bool> ExistsActiveByCodigoAsync(string codigo, CancellationToken ct = default);
|
||||
Task<int> AddAsync(Rol rol, CancellationToken ct = default);
|
||||
Task<bool> UpdateAsync(string codigo, string nombre, string? descripcion, bool activo, CancellationToken ct = default);
|
||||
Task<bool> HasActiveUsuariosAsync(string codigo, CancellationToken ct = default);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace SIGCM2.Application.Roles.Create;
|
||||
|
||||
public sealed record CreateRolCommand(
|
||||
string Codigo,
|
||||
string Nombre,
|
||||
string? Descripcion);
|
||||
@@ -0,0 +1,36 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Roles.Dtos;
|
||||
using SIGCM2.Domain.Entities;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Roles.Create;
|
||||
|
||||
public sealed class CreateRolCommandHandler : ICommandHandler<CreateRolCommand, RolCreatedDto>
|
||||
{
|
||||
private readonly IRolRepository _repository;
|
||||
|
||||
public CreateRolCommandHandler(IRolRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<RolCreatedDto> Handle(CreateRolCommand command)
|
||||
{
|
||||
// Check-then-insert: explicit check produces a clear 409 message.
|
||||
// SqlException 2627 (UQ violation) acts as race-condition fallback — caught in ExceptionFilter.
|
||||
var existing = await _repository.GetByCodigoAsync(command.Codigo);
|
||||
if (existing is not null)
|
||||
throw new RolAlreadyExistsException(command.Codigo);
|
||||
|
||||
var rol = Rol.ForCreation(command.Codigo, command.Nombre, command.Descripcion);
|
||||
var newId = await _repository.AddAsync(rol);
|
||||
|
||||
return new RolCreatedDto(
|
||||
Id: newId,
|
||||
Codigo: rol.Codigo,
|
||||
Nombre: rol.Nombre,
|
||||
Descripcion: rol.Descripcion,
|
||||
Activo: rol.Activo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SIGCM2.Application.Roles.Create;
|
||||
|
||||
public sealed class CreateRolCommandValidator : AbstractValidator<CreateRolCommand>
|
||||
{
|
||||
private const int CodigoMinLength = 3;
|
||||
private const int CodigoMaxLength = 30;
|
||||
private const int NombreMaxLength = 60;
|
||||
private const int DescripcionMaxLength = 250;
|
||||
|
||||
public CreateRolCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.Codigo)
|
||||
.NotEmpty().WithMessage("El código es requerido.")
|
||||
.Length(CodigoMinLength, CodigoMaxLength)
|
||||
.WithMessage($"El código debe tener entre {CodigoMinLength} y {CodigoMaxLength} caracteres.")
|
||||
.Matches(@"^[a-z][a-z0-9_]*$")
|
||||
.WithMessage("El código debe empezar con una letra minúscula y contener solo minúsculas, dígitos o guion bajo.");
|
||||
|
||||
RuleFor(x => x.Nombre)
|
||||
.NotEmpty().WithMessage("El nombre es requerido.")
|
||||
.MaximumLength(NombreMaxLength)
|
||||
.WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
|
||||
|
||||
RuleFor(x => x.Descripcion)
|
||||
.MaximumLength(DescripcionMaxLength)
|
||||
.WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.")
|
||||
.When(x => x.Descripcion is not null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Roles.Deactivate;
|
||||
|
||||
public sealed record DeactivateRolCommand(string Codigo);
|
||||
@@ -0,0 +1,36 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Roles.Dtos;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Roles.Deactivate;
|
||||
|
||||
public sealed class DeactivateRolCommandHandler : ICommandHandler<DeactivateRolCommand, RolDto>
|
||||
{
|
||||
private readonly IRolRepository _repository;
|
||||
|
||||
public DeactivateRolCommandHandler(IRolRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<RolDto> Handle(DeactivateRolCommand command)
|
||||
{
|
||||
var existing = await _repository.GetByCodigoAsync(command.Codigo)
|
||||
?? throw new RolNotFoundException(command.Codigo);
|
||||
|
||||
// Guard: block soft-delete when active usuarios reference this rol.
|
||||
if (await _repository.HasActiveUsuariosAsync(command.Codigo))
|
||||
throw new RolInUseException(command.Codigo);
|
||||
|
||||
var updated = await _repository.UpdateAsync(
|
||||
existing.Codigo, existing.Nombre, existing.Descripcion, activo: false);
|
||||
if (!updated)
|
||||
throw new RolNotFoundException(command.Codigo);
|
||||
|
||||
var rol = await _repository.GetByCodigoAsync(command.Codigo)
|
||||
?? throw new RolNotFoundException(command.Codigo);
|
||||
|
||||
return new RolDto(rol.Id, rol.Codigo, rol.Nombre, rol.Descripcion, rol.Activo, rol.FechaCreacion, rol.FechaModificacion);
|
||||
}
|
||||
}
|
||||
8
src/api/SIGCM2.Application/Roles/Dtos/RolCreatedDto.cs
Normal file
8
src/api/SIGCM2.Application/Roles/Dtos/RolCreatedDto.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace SIGCM2.Application.Roles.Dtos;
|
||||
|
||||
public sealed record RolCreatedDto(
|
||||
int Id,
|
||||
string Codigo,
|
||||
string Nombre,
|
||||
string? Descripcion,
|
||||
bool Activo);
|
||||
10
src/api/SIGCM2.Application/Roles/Dtos/RolDto.cs
Normal file
10
src/api/SIGCM2.Application/Roles/Dtos/RolDto.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace SIGCM2.Application.Roles.Dtos;
|
||||
|
||||
public sealed record RolDto(
|
||||
int Id,
|
||||
string Codigo,
|
||||
string Nombre,
|
||||
string? Descripcion,
|
||||
bool Activo,
|
||||
DateTime FechaCreacion,
|
||||
DateTime? FechaModificacion);
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Roles.Get;
|
||||
|
||||
public sealed record GetRolByCodigoQuery(string Codigo);
|
||||
@@ -0,0 +1,25 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Roles.Dtos;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Roles.Get;
|
||||
|
||||
public sealed class GetRolByCodigoQueryHandler : ICommandHandler<GetRolByCodigoQuery, RolDto>
|
||||
{
|
||||
private readonly IRolRepository _repository;
|
||||
|
||||
public GetRolByCodigoQueryHandler(IRolRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<RolDto> Handle(GetRolByCodigoQuery query)
|
||||
{
|
||||
var rol = await _repository.GetByCodigoAsync(query.Codigo);
|
||||
if (rol is null)
|
||||
throw new RolNotFoundException(query.Codigo);
|
||||
|
||||
return new RolDto(rol.Id, rol.Codigo, rol.Nombre, rol.Descripcion, rol.Activo, rol.FechaCreacion, rol.FechaModificacion);
|
||||
}
|
||||
}
|
||||
3
src/api/SIGCM2.Application/Roles/List/ListRolesQuery.cs
Normal file
3
src/api/SIGCM2.Application/Roles/List/ListRolesQuery.cs
Normal file
@@ -0,0 +1,3 @@
|
||||
namespace SIGCM2.Application.Roles.List;
|
||||
|
||||
public sealed record ListRolesQuery();
|
||||
@@ -0,0 +1,23 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Roles.Dtos;
|
||||
|
||||
namespace SIGCM2.Application.Roles.List;
|
||||
|
||||
public sealed class ListRolesQueryHandler : ICommandHandler<ListRolesQuery, IReadOnlyList<RolDto>>
|
||||
{
|
||||
private readonly IRolRepository _repository;
|
||||
|
||||
public ListRolesQueryHandler(IRolRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RolDto>> Handle(ListRolesQuery query)
|
||||
{
|
||||
var roles = await _repository.ListAsync();
|
||||
return roles
|
||||
.Select(r => new RolDto(r.Id, r.Codigo, r.Nombre, r.Descripcion, r.Activo, r.FechaCreacion, r.FechaModificacion))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace SIGCM2.Application.Roles.Update;
|
||||
|
||||
public sealed record UpdateRolCommand(
|
||||
string Codigo,
|
||||
string Nombre,
|
||||
string? Descripcion,
|
||||
bool Activo);
|
||||
@@ -0,0 +1,30 @@
|
||||
using SIGCM2.Application.Abstractions;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Application.Roles.Dtos;
|
||||
using SIGCM2.Domain.Exceptions;
|
||||
|
||||
namespace SIGCM2.Application.Roles.Update;
|
||||
|
||||
public sealed class UpdateRolCommandHandler : ICommandHandler<UpdateRolCommand, RolDto>
|
||||
{
|
||||
private readonly IRolRepository _repository;
|
||||
|
||||
public UpdateRolCommandHandler(IRolRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<RolDto> Handle(UpdateRolCommand command)
|
||||
{
|
||||
var updated = await _repository.UpdateAsync(
|
||||
command.Codigo, command.Nombre, command.Descripcion, command.Activo);
|
||||
|
||||
if (!updated)
|
||||
throw new RolNotFoundException(command.Codigo);
|
||||
|
||||
var rol = await _repository.GetByCodigoAsync(command.Codigo)
|
||||
?? throw new RolNotFoundException(command.Codigo);
|
||||
|
||||
return new RolDto(rol.Id, rol.Codigo, rol.Nombre, rol.Descripcion, rol.Activo, rol.FechaCreacion, rol.FechaModificacion);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace SIGCM2.Application.Roles.Update;
|
||||
|
||||
public sealed class UpdateRolCommandValidator : AbstractValidator<UpdateRolCommand>
|
||||
{
|
||||
private const int NombreMaxLength = 60;
|
||||
private const int DescripcionMaxLength = 250;
|
||||
|
||||
public UpdateRolCommandValidator()
|
||||
{
|
||||
// Codigo is taken from the URL route — we don't re-validate format here,
|
||||
// but we require it to be non-empty so handler always has a target to match.
|
||||
RuleFor(x => x.Codigo)
|
||||
.NotEmpty().WithMessage("El código es requerido.");
|
||||
|
||||
RuleFor(x => x.Nombre)
|
||||
.NotEmpty().WithMessage("El nombre es requerido.")
|
||||
.MaximumLength(NombreMaxLength)
|
||||
.WithMessage($"El nombre no puede superar los {NombreMaxLength} caracteres.");
|
||||
|
||||
RuleFor(x => x.Descripcion)
|
||||
.MaximumLength(DescripcionMaxLength)
|
||||
.WithMessage($"La descripción no puede superar los {DescripcionMaxLength} caracteres.")
|
||||
.When(x => x.Descripcion is not null);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,13 @@ namespace SIGCM2.Application.Usuarios.Create;
|
||||
|
||||
public sealed class CreateUsuarioCommandValidator : AbstractValidator<CreateUsuarioCommand>
|
||||
{
|
||||
private static readonly string[] ValidRoles = ["admin", "vendedor", "tasador", "consulta"];
|
||||
// Whitelist aligned with canonical seeds in dbo.Rol (migration V003).
|
||||
// Phase 5 of UDT-004 will replace this array with an async lookup against IRolRepository.
|
||||
private static readonly string[] ValidRoles =
|
||||
[
|
||||
"admin", "cajero", "operador_ctacte", "picadora",
|
||||
"jefe_publicidad", "productor", "diagramacion", "reportes"
|
||||
];
|
||||
|
||||
private const int UsernameMinLength = 3;
|
||||
private const int UsernameMaxLength = 50;
|
||||
|
||||
44
src/api/SIGCM2.Domain/Entities/Rol.cs
Normal file
44
src/api/SIGCM2.Domain/Entities/Rol.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
namespace SIGCM2.Domain.Entities;
|
||||
|
||||
public sealed class Rol
|
||||
{
|
||||
public int Id { get; }
|
||||
public string Codigo { get; }
|
||||
public string Nombre { get; }
|
||||
public string? Descripcion { get; }
|
||||
public bool Activo { get; }
|
||||
public DateTime FechaCreacion { get; }
|
||||
public DateTime? FechaModificacion { get; }
|
||||
|
||||
public Rol(
|
||||
int id,
|
||||
string codigo,
|
||||
string nombre,
|
||||
string? descripcion,
|
||||
bool activo,
|
||||
DateTime fechaCreacion,
|
||||
DateTime? fechaModificacion)
|
||||
{
|
||||
Id = id;
|
||||
Codigo = codigo;
|
||||
Nombre = nombre;
|
||||
Descripcion = descripcion;
|
||||
Activo = activo;
|
||||
FechaCreacion = fechaCreacion;
|
||||
FechaModificacion = fechaModificacion;
|
||||
}
|
||||
|
||||
// Factory for creating a new Rol (Id=0 — DB assigns via IDENTITY; Activo=true; FechaCreacion set by DB default).
|
||||
public static Rol ForCreation(string codigo, string nombre, string? descripcion)
|
||||
{
|
||||
return new Rol(
|
||||
id: 0,
|
||||
codigo: codigo,
|
||||
nombre: nombre,
|
||||
descripcion: descripcion,
|
||||
activo: true,
|
||||
fechaCreacion: default,
|
||||
fechaModificacion: null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
public sealed class RolAlreadyExistsException : Exception
|
||||
{
|
||||
public string Codigo { get; }
|
||||
|
||||
public RolAlreadyExistsException(string codigo)
|
||||
: base($"El rol '{codigo}' ya existe.")
|
||||
{
|
||||
Codigo = codigo;
|
||||
}
|
||||
}
|
||||
12
src/api/SIGCM2.Domain/Exceptions/RolInUseException.cs
Normal file
12
src/api/SIGCM2.Domain/Exceptions/RolInUseException.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
public sealed class RolInUseException : Exception
|
||||
{
|
||||
public string Codigo { get; }
|
||||
|
||||
public RolInUseException(string codigo)
|
||||
: base($"El rol '{codigo}' no puede desactivarse porque existen usuarios activos que lo referencian.")
|
||||
{
|
||||
Codigo = codigo;
|
||||
}
|
||||
}
|
||||
12
src/api/SIGCM2.Domain/Exceptions/RolNotFoundException.cs
Normal file
12
src/api/SIGCM2.Domain/Exceptions/RolNotFoundException.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace SIGCM2.Domain.Exceptions;
|
||||
|
||||
public sealed class RolNotFoundException : Exception
|
||||
{
|
||||
public string Codigo { get; }
|
||||
|
||||
public RolNotFoundException(string codigo)
|
||||
: base($"El rol '{codigo}' no existe.")
|
||||
{
|
||||
Codigo = codigo;
|
||||
}
|
||||
}
|
||||
129
src/api/SIGCM2.Infrastructure/Persistence/RolRepository.cs
Normal file
129
src/api/SIGCM2.Infrastructure/Persistence/RolRepository.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
using Dapper;
|
||||
using SIGCM2.Application.Abstractions.Persistence;
|
||||
using SIGCM2.Domain.Entities;
|
||||
|
||||
namespace SIGCM2.Infrastructure.Persistence;
|
||||
|
||||
public sealed class RolRepository : IRolRepository
|
||||
{
|
||||
private readonly SqlConnectionFactory _connectionFactory;
|
||||
|
||||
public RolRepository(SqlConnectionFactory connectionFactory)
|
||||
{
|
||||
_connectionFactory = connectionFactory;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Rol>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT Id, Codigo, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion
|
||||
FROM dbo.Rol
|
||||
ORDER BY Id
|
||||
""";
|
||||
|
||||
await using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var rows = await connection.QueryAsync<RolRow>(sql);
|
||||
return rows.Select(MapRow).ToList();
|
||||
}
|
||||
|
||||
public async Task<Rol?> GetByCodigoAsync(string codigo, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT Id, Codigo, Nombre, Descripcion, Activo, FechaCreacion, FechaModificacion
|
||||
FROM dbo.Rol
|
||||
WHERE Codigo = @Codigo
|
||||
""";
|
||||
|
||||
await using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var row = await connection.QuerySingleOrDefaultAsync<RolRow>(sql, new { Codigo = codigo });
|
||||
return row is null ? null : MapRow(row);
|
||||
}
|
||||
|
||||
public async Task<bool> ExistsActiveByCodigoAsync(string codigo, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(1) FROM dbo.Rol WHERE Codigo = @Codigo AND Activo = 1
|
||||
""";
|
||||
|
||||
await using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var count = await connection.ExecuteScalarAsync<int>(sql, new { Codigo = codigo });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
public async Task<int> AddAsync(Rol rol, CancellationToken ct = default)
|
||||
{
|
||||
// DF handles: Activo (1), FechaCreacion (SYSUTCDATETIME()).
|
||||
const string sql = """
|
||||
INSERT INTO dbo.Rol (Codigo, Nombre, Descripcion)
|
||||
OUTPUT INSERTED.Id
|
||||
VALUES (@Codigo, @Nombre, @Descripcion)
|
||||
""";
|
||||
|
||||
await using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
return await connection.ExecuteScalarAsync<int>(sql, new
|
||||
{
|
||||
rol.Codigo,
|
||||
rol.Nombre,
|
||||
rol.Descripcion,
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<bool> UpdateAsync(string codigo, string nombre, string? descripcion, bool activo, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE dbo.Rol
|
||||
SET Nombre = @Nombre,
|
||||
Descripcion = @Descripcion,
|
||||
Activo = @Activo,
|
||||
FechaModificacion = SYSUTCDATETIME()
|
||||
WHERE Codigo = @Codigo;
|
||||
SELECT @@ROWCOUNT;
|
||||
""";
|
||||
|
||||
await using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var rows = await connection.ExecuteScalarAsync<int>(sql, new { Codigo = codigo, Nombre = nombre, Descripcion = descripcion, Activo = activo });
|
||||
return rows > 0;
|
||||
}
|
||||
|
||||
public async Task<bool> HasActiveUsuariosAsync(string codigo, CancellationToken ct = default)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT COUNT(1) FROM dbo.Usuario WHERE Rol = @Codigo AND Activo = 1
|
||||
""";
|
||||
|
||||
await using var connection = _connectionFactory.CreateConnection();
|
||||
await connection.OpenAsync(ct);
|
||||
|
||||
var count = await connection.ExecuteScalarAsync<int>(sql, new { Codigo = codigo });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
private static Rol MapRow(RolRow row)
|
||||
=> new(
|
||||
id: row.Id,
|
||||
codigo: row.Codigo,
|
||||
nombre: row.Nombre,
|
||||
descripcion: row.Descripcion,
|
||||
activo: row.Activo,
|
||||
fechaCreacion: row.FechaCreacion,
|
||||
fechaModificacion: row.FechaModificacion);
|
||||
|
||||
private sealed record RolRow(
|
||||
int Id,
|
||||
string Codigo,
|
||||
string Nombre,
|
||||
string? Descripcion,
|
||||
bool Activo,
|
||||
DateTime FechaCreacion,
|
||||
DateTime? FechaModificacion);
|
||||
}
|
||||
Reference in New Issue
Block a user