2026-01-08 04:00:56 +00:00
|
|
|
# DI-002: Modelo de Dominio
|
|
|
|
|
|
|
|
|
|
**Proyecto:** Sistema de Registro de Estudiantes
|
|
|
|
|
**Fecha:** 2026-01-07
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 1. Diagrama de Entidades
|
|
|
|
|
|
|
|
|
|
```
|
2026-01-09 12:43:57 +00:00
|
|
|
┌─────────────────┐ ┌─────────────────────────┐
|
|
|
|
|
│ PROFESSOR │ │ STUDENT │
|
|
|
|
|
├─────────────────┤ ├─────────────────────────┤
|
|
|
|
|
│ Id: int (PK) │ │ Id: int (PK) │
|
|
|
|
|
│ Name: string │ │ Name: string │
|
|
|
|
|
└────────┬────────┘ │ Email: Email │
|
|
|
|
|
│ │ ActivationCodeHash? │ ← Nuevo
|
|
|
|
|
│ 1:2 │ ActivationExpiresAt? │ ← Nuevo
|
|
|
|
|
▼ │ IsActivated (computed) │ ← Nuevo
|
|
|
|
|
┌─────────────────┐ │ RowVersion │
|
|
|
|
|
│ SUBJECT │ └────────┬────────────────┘
|
|
|
|
|
├─────────────────┤ │
|
|
|
|
|
│ Id: int (PK) │ │ 0..3
|
|
|
|
|
│ Name: string │ ▼
|
|
|
|
|
│ Credits: 3 │ ┌─────────────────┐
|
|
|
|
|
│ ProfessorId: FK │◄──────│ ENROLLMENT │
|
|
|
|
|
└─────────────────┘ 1:N ├─────────────────┤
|
|
|
|
|
│ Id: int (PK) │
|
|
|
|
|
┌─────────────────┐ │ StudentId: FK │
|
|
|
|
|
│ USER │ │ SubjectId: FK │
|
|
|
|
|
├─────────────────┤ │ EnrolledAt │
|
|
|
|
|
│ Id: int (PK) │ └─────────────────┘
|
|
|
|
|
│ Username │
|
|
|
|
|
│ PasswordHash │
|
|
|
|
|
│ RecoveryCodeHash│
|
|
|
|
|
│ Role (Admin/ │
|
|
|
|
|
│ Student) │
|
|
|
|
|
│ StudentId?: FK │───────► 0..1 Student
|
|
|
|
|
│ CreatedAt │
|
|
|
|
|
│ LastLoginAt? │
|
|
|
|
|
└─────────────────┘
|
2026-01-08 04:00:56 +00:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 2. Entidades
|
|
|
|
|
|
|
|
|
|
### Student (Aggregate Root)
|
|
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
public class Student
|
|
|
|
|
{
|
2026-01-09 12:43:57 +00:00
|
|
|
public const int MaxEnrollments = 3;
|
|
|
|
|
|
2026-01-08 04:00:56 +00:00
|
|
|
public int Id { get; private set; }
|
|
|
|
|
public string Name { get; private set; }
|
|
|
|
|
public Email Email { get; private set; }
|
2026-01-09 12:43:57 +00:00
|
|
|
|
|
|
|
|
// Campos de Activación (nuevo flujo)
|
|
|
|
|
public string? ActivationCodeHash { get; private set; }
|
|
|
|
|
public DateTime? ActivationExpiresAt { get; private set; }
|
|
|
|
|
public bool IsActivated => ActivationCodeHash == null;
|
2026-01-08 04:00:56 +00:00
|
|
|
|
|
|
|
|
private readonly List<Enrollment> _enrollments = new();
|
|
|
|
|
public IReadOnlyCollection<Enrollment> Enrollments => _enrollments;
|
|
|
|
|
|
|
|
|
|
public int TotalCredits => _enrollments.Count * 3;
|
|
|
|
|
|
|
|
|
|
public void Enroll(Subject subject, IEnrollmentPolicy policy)
|
|
|
|
|
{
|
|
|
|
|
policy.Validate(this, subject);
|
|
|
|
|
_enrollments.Add(new Enrollment(this, subject));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void Unenroll(int subjectId)
|
|
|
|
|
{
|
|
|
|
|
var enrollment = _enrollments.FirstOrDefault(e => e.SubjectId == subjectId);
|
|
|
|
|
if (enrollment != null) _enrollments.Remove(enrollment);
|
|
|
|
|
}
|
2026-01-09 12:43:57 +00:00
|
|
|
|
|
|
|
|
// Métodos de activación
|
|
|
|
|
public void SetActivationCode(string codeHash, TimeSpan expiresIn)
|
|
|
|
|
{
|
|
|
|
|
ActivationCodeHash = codeHash;
|
|
|
|
|
ActivationExpiresAt = DateTime.UtcNow.Add(expiresIn);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public void ClearActivationCode()
|
|
|
|
|
{
|
|
|
|
|
ActivationCodeHash = null;
|
|
|
|
|
ActivationExpiresAt = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public bool IsActivationExpired() =>
|
|
|
|
|
ActivationExpiresAt.HasValue && DateTime.UtcNow > ActivationExpiresAt.Value;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### User (Autenticación)
|
|
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
public class User
|
|
|
|
|
{
|
|
|
|
|
public int Id { get; private set; }
|
|
|
|
|
public string Username { get; private set; } // Almacenado en minúsculas
|
|
|
|
|
public string PasswordHash { get; private set; } // PBKDF2-SHA256
|
|
|
|
|
public string RecoveryCodeHash { get; private set; }
|
|
|
|
|
public string Role { get; private set; } // "Admin" | "Student"
|
|
|
|
|
public int? StudentId { get; private set; } // FK opcional a Student
|
|
|
|
|
public DateTime CreatedAt { get; private set; }
|
|
|
|
|
public DateTime? LastLoginAt { get; private set; }
|
|
|
|
|
|
|
|
|
|
public Student? Student { get; private set; } // Navegación
|
2026-01-08 04:00:56 +00:00
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Subject
|
|
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
public class Subject
|
|
|
|
|
{
|
|
|
|
|
public int Id { get; private set; }
|
|
|
|
|
public string Name { get; private set; }
|
|
|
|
|
public int Credits { get; } = 3; // Constante: RN-002
|
|
|
|
|
public int ProfessorId { get; private set; }
|
|
|
|
|
public Professor Professor { get; private set; }
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Professor
|
|
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
public class Professor
|
|
|
|
|
{
|
|
|
|
|
public int Id { get; private set; }
|
|
|
|
|
public string Name { get; private set; }
|
|
|
|
|
|
|
|
|
|
private readonly List<Subject> _subjects = new();
|
|
|
|
|
public IReadOnlyCollection<Subject> Subjects => _subjects;
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
### Enrollment
|
|
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
public class Enrollment
|
|
|
|
|
{
|
|
|
|
|
public int Id { get; private set; }
|
|
|
|
|
public int StudentId { get; private set; }
|
|
|
|
|
public int SubjectId { get; private set; }
|
|
|
|
|
public DateTime EnrolledAt { get; private set; }
|
|
|
|
|
|
|
|
|
|
public Student Student { get; private set; }
|
|
|
|
|
public Subject Subject { get; private set; }
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 3. Value Objects
|
|
|
|
|
|
|
|
|
|
### Email
|
|
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
public record Email
|
|
|
|
|
{
|
|
|
|
|
public string Value { get; }
|
|
|
|
|
|
|
|
|
|
private Email(string value) => Value = value;
|
|
|
|
|
|
|
|
|
|
public static Result<Email> Create(string email)
|
|
|
|
|
{
|
|
|
|
|
if (string.IsNullOrWhiteSpace(email))
|
|
|
|
|
return Result.Failure<Email>("Email requerido");
|
|
|
|
|
|
|
|
|
|
if (!Regex.IsMatch(email, @"^[^@\s]+@[^@\s]+\.[^@\s]+$"))
|
|
|
|
|
return Result.Failure<Email>("Formato de email inválido");
|
|
|
|
|
|
|
|
|
|
return Result.Success(new Email(email.ToLowerInvariant()));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 4. Domain Service
|
|
|
|
|
|
|
|
|
|
### EnrollmentDomainService (Implementa RN-003 y RN-005)
|
|
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
public class EnrollmentDomainService : IEnrollmentPolicy
|
|
|
|
|
{
|
|
|
|
|
public void Validate(Student student, Subject newSubject)
|
|
|
|
|
{
|
|
|
|
|
// RN-003: Máximo 3 materias
|
|
|
|
|
if (student.Enrollments.Count >= 3)
|
|
|
|
|
throw new MaxEnrollmentsExceededException(student.Id);
|
|
|
|
|
|
|
|
|
|
// RN-005: No repetir profesor
|
|
|
|
|
var professorIds = student.Enrollments
|
|
|
|
|
.Select(e => e.Subject.ProfessorId)
|
|
|
|
|
.ToHashSet();
|
|
|
|
|
|
|
|
|
|
if (professorIds.Contains(newSubject.ProfessorId))
|
|
|
|
|
throw new SameProfessorConstraintException(
|
|
|
|
|
student.Id,
|
|
|
|
|
newSubject.ProfessorId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 5. Excepciones de Dominio
|
|
|
|
|
|
|
|
|
|
```csharp
|
|
|
|
|
public class MaxEnrollmentsExceededException : DomainException
|
|
|
|
|
{
|
|
|
|
|
public MaxEnrollmentsExceededException(int studentId)
|
|
|
|
|
: base($"Estudiante {studentId} ya tiene 3 materias inscritas") { }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public class SameProfessorConstraintException : DomainException
|
|
|
|
|
{
|
|
|
|
|
public SameProfessorConstraintException(int studentId, int professorId)
|
|
|
|
|
: base($"Estudiante {studentId} ya tiene materia con profesor {professorId}") { }
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 6. Invariantes del Dominio
|
|
|
|
|
|
|
|
|
|
| Invariante | Entidad | Validación |
|
|
|
|
|
|------------|---------|------------|
|
|
|
|
|
| Email único | Student | DB Constraint + Validator |
|
|
|
|
|
| Email válido | Student | Value Object |
|
|
|
|
|
| Max 3 inscripciones | Student | Domain Service |
|
|
|
|
|
| No repetir profesor | Student | Domain Service |
|
|
|
|
|
| 3 créditos/materia | Subject | Constante |
|
|
|
|
|
| 2 materias/profesor | Professor | Seed Data |
|
2026-01-09 12:43:57 +00:00
|
|
|
| Username único | User | DB Constraint |
|
|
|
|
|
| Código activación expira | Student | ActivationExpiresAt |
|
|
|
|
|
| Student ↔ User (1:0..1) | User | StudentId nullable |
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 7. Flujo de Activación
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
|
|
|
│ ADMIN │ │ STUDENT │ │ USER │
|
|
|
|
|
│ (creates) │───►│ (pending) │ │ (not yet) │
|
|
|
|
|
└──────────────┘ └──────┬───────┘ └──────────────┘
|
|
|
|
|
│
|
|
|
|
|
SetActivationCode()
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
┌──────────────┐
|
|
|
|
|
│ STUDENT │
|
|
|
|
|
│ (has code) │
|
|
|
|
|
└──────┬───────┘
|
|
|
|
|
│
|
|
|
|
|
Activate(username, password)
|
|
|
|
|
│
|
|
|
|
|
▼
|
|
|
|
|
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
|
|
|
|
│ ADMIN │ │ STUDENT │◄───│ USER │
|
|
|
|
|
│ (creates) │ │ (activated) │ │ StudentId=X │
|
|
|
|
|
└──────────────┘ └──────────────┘ └──────────────┘
|
|
|
|
|
```
|