feat(domain): V008 migration + Usuario with-methods + DomainException hierarchy [UDT-008]

This commit is contained in:
2026-04-15 17:36:46 -03:00
parent 5ddc5ddf02
commit d1f7b3805b
8 changed files with 383 additions and 5 deletions

View File

@@ -12,6 +12,11 @@ public sealed class Usuario
public string PermisosJson { get; }
public bool Activo { get; }
// UDT-008: new properties
public DateTime? FechaModificacion { get; }
public DateTime? UltimoLogin { get; }
public bool MustChangePassword { get; }
public Usuario(
int id,
string username,
@@ -21,7 +26,10 @@ public sealed class Usuario
string? email,
string rol,
string permisosJson,
bool activo)
bool activo,
DateTime? fechaModificacion = null,
DateTime? ultimoLogin = null,
bool mustChangePassword = false)
{
Id = id;
Username = username;
@@ -32,11 +40,14 @@ public sealed class Usuario
Rol = rol;
PermisosJson = permisosJson;
Activo = activo;
FechaModificacion = fechaModificacion;
UltimoLogin = ultimoLogin;
MustChangePassword = mustChangePassword;
}
/// <summary>
/// Factory for creating a new user (no Id — DB assigns via IDENTITY).
/// Defaults: Activo=true, PermisosJson="[]".
/// Defaults: Activo=true, PermisosJson="[]", MustChangePassword=false.
/// </summary>
public static Usuario ForCreation(
string username,
@@ -55,6 +66,87 @@ public sealed class Usuario
email: email,
rol: rol,
permisosJson: "[]",
activo: true);
activo: true,
fechaModificacion: null,
ultimoLogin: null,
mustChangePassword: false);
}
// ── UDT-008: copy-with factory methods ────────────────────────────────────
/// <summary>
/// Returns a new instance with updated profile fields.
/// Sets FechaModificacion = UtcNow. Username and PasswordHash are immutable.
/// </summary>
public Usuario WithUpdatedProfile(string nombre, string apellido, string? email, string rol, bool activo)
=> new(
id: Id,
username: Username,
passwordHash: PasswordHash,
nombre: nombre,
apellido: apellido,
email: email,
rol: rol,
permisosJson: PermisosJson,
activo: activo,
fechaModificacion: DateTime.UtcNow,
ultimoLogin: UltimoLogin,
mustChangePassword: MustChangePassword);
/// <summary>
/// Returns a new instance with a new password hash and mustChangePassword flag.
/// Sets FechaModificacion = UtcNow.
/// </summary>
public Usuario WithNewPasswordHash(string hash, bool mustChangePassword)
=> new(
id: Id,
username: Username,
passwordHash: hash,
nombre: Nombre,
apellido: Apellido,
email: Email,
rol: Rol,
permisosJson: PermisosJson,
activo: Activo,
fechaModificacion: DateTime.UtcNow,
ultimoLogin: UltimoLogin,
mustChangePassword: mustChangePassword);
/// <summary>
/// Returns a new instance with only the MustChangePassword flag changed.
/// Sets FechaModificacion = UtcNow.
/// </summary>
public Usuario WithMustChangePassword(bool value)
=> new(
id: Id,
username: Username,
passwordHash: PasswordHash,
nombre: Nombre,
apellido: Apellido,
email: Email,
rol: Rol,
permisosJson: PermisosJson,
activo: Activo,
fechaModificacion: DateTime.UtcNow,
ultimoLogin: UltimoLogin,
mustChangePassword: value);
/// <summary>
/// Returns a new instance with only UltimoLogin updated.
/// Does NOT touch FechaModificacion.
/// </summary>
public Usuario WithUltimoLogin(DateTime utcNow)
=> new(
id: Id,
username: Username,
passwordHash: PasswordHash,
nombre: Nombre,
apellido: Apellido,
email: Email,
rol: Rol,
permisosJson: PermisosJson,
activo: Activo,
fechaModificacion: FechaModificacion,
ultimoLogin: utcNow,
mustChangePassword: MustChangePassword);
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when an admin attempts to reset their own password via the admin reset endpoint.
/// Admin must use the self-service change password endpoint instead.
/// </summary>
public sealed class CannotSelfResetException : DomainException
{
public CannotSelfResetException()
: base("Un administrador no puede resetear su propia contraseña. Use el endpoint de cambio de contraseña propio.") { }
}

View File

@@ -0,0 +1,10 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Base class for all domain-level exceptions in SIGCM2.
/// </summary>
public abstract class DomainException : Exception
{
protected DomainException(string message) : base(message) { }
protected DomainException(string message, Exception innerException) : base(message, innerException) { }
}

View File

@@ -0,0 +1,11 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when an operation would remove the last active admin from the system,
/// causing a lockout condition.
/// </summary>
public sealed class LastAdminLockoutException : DomainException
{
public LastAdminLockoutException()
: base("No se puede desactivar o cambiar el rol del último administrador activo.") { }
}

View File

@@ -0,0 +1,15 @@
namespace SIGCM2.Domain.Exceptions;
/// <summary>
/// Thrown when a requested user does not exist in the system.
/// </summary>
public sealed class UsuarioNotFoundException : DomainException
{
public int Id { get; }
public UsuarioNotFoundException(int id)
: base($"El usuario con id '{id}' no existe.")
{
Id = id;
}
}