Saltar a contenido

Sesión: Implementación de propiedad renews en getPaginatedStudents

Fecha: 2025-10-02
Objetivo: Agregar una propiedad renews que indique si un estudiante tiene matrículas en el curso escolar actual.


Problema Inicial

El endpoint getPaginatedStudents devolvía estudiantes pero no indicaba si tenían matrículas (registers) en el curso escolar actual. Se necesitaba:

  1. Filtrar registers por el curso escolar actual (último curso)
  2. Agregar una propiedad renews: boolean que indique:
  3. true si el estudiante tiene registros en el curso actual
  4. false si el array registers está vacío

Patrón Establecido en el Proyecto

Después de analizar múltiples casos de uso, se identificó el patrón del proyecto:

✅ Obtener el último curso escolar en el CASO DE USO, NO en el repositorio

Ejemplos del patrón existente:

  • GetScheduleEmployeeByIdUseCase
  • GetRegistersByStudetUseCase
  • GetAttendanceRecordsByStudentIdUseCase
  • GetDashboardStudentUseCase
// Patrón encontrado en múltiples casos de uso
if(!schoolCourseId){
    const schoolCourse = await this.schoolCourseRepository.getLastCourse();
    schoolCourseId = schoolCourse?.getId();
}

Cambios Implementados

1. GetPaginatedStudentsUseCase.ts

Ubicación: src/application/use-cases/student/GetPaginatedStudentsUseCase.ts

Cambios: - Agregado DomainSchoolCourseRepository en el constructor - Obtener el último curso escolar si no se proporciona - Actualizada interfaz PaginatedStudentsResult para incluir studentsRaw?: any[]

interface PaginatedStudentsResult {
  students: Student[];
  studentsRaw?: any[];  // ← NUEVO
  totalItems: number;
  currentPage: number;
  totalPages: number;
  pageSize: number;
}

export class GetPaginatedStudentsUseCase {
  constructor(
    private readonly repository: DomainStudentRepository,
    private readonly schoolCourseRepository: DomainSchoolCourseRepository  // ← NUEVO
  ) {}

  async execute({ page = 1, limit = 20, search, statuses, subjectId, schoolCourseId }: GetPaginatedStudentsDTO): Promise<PaginatedStudentsResult> {
    const sanitizedPage = Number.isFinite(page) && page > 0 ? Math.floor(page) : 1;
    const sanitizedLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : 20;

    const lastCourse = await this.schoolCourseRepository.getLastCourse();  // ← NUEVO

    const { students, studentsRaw, totalItems } = await this.repository.getPaginatedStudents(
      sanitizedPage, 
      sanitizedLimit, 
      search, 
      statuses,
      subjectId,
      lastCourse.getId()  // ← NUEVO: Pasamos el ID del último curso
    );
    const totalPages = Math.ceil(totalItems / sanitizedLimit) || 1;

    return {
      students,
      studentsRaw,  // ← NUEVO
      totalItems,
      currentPage: sanitizedPage,
      totalPages,
      pageSize: sanitizedLimit,
    };
  }
}

2. app.ts - Inyección de dependencia

Ubicación: src/app.ts

Cambio:

// ANTES
const getPaginatedStudentsUseCase = new GetPaginatedStudentsUseCase(studentRepository);

// DESPUÉS
const getPaginatedStudentsUseCase = new GetPaginatedStudentsUseCase(studentRepository, schoolCourseRepository);


3. StudentRepositoryImpl.ts

Ubicación: src/infrastructure/adapters/persistence/StudentRepositoryImpl.ts

Cambios: - Agregado filtro where en el include de StudentRegistration para filtrar por schoolCourseId - Agregado required: false para hacer LEFT JOIN (incluye estudiantes sin registros) - Agregado studentsRaw al return para que el controlador pueda calcular renews - Actualizada firma del método para incluir studentsRaw en el tipo de retorno

async getPaginatedStudents(
  page: number, 
  limit: number, 
  search?: string, 
  statuses?: string[], 
  subjectId?: number, 
  schoolCourseId?: number
): Promise<{ students: Student[]; studentsRaw?: any[]; totalItems: number }> {  // ← ACTUALIZADO

   // ... código de filtros ...

   const { rows: students, count: totalItems } = await StudentModel.findAndCountAll({
       where: whereClause,
       include: [
           { model: UserModel },
           {
               model: GuardianModel,
               include: [{ model: UserModel }]
           },
           {
               where: {
                   schoolCoursesId: schoolCourseId  // ← NUEVO: Filtro por curso
               },
               model: StudentRegistration,
               as: 'registers',
               required: false  // ← NUEVO: LEFT JOIN para incluir estudiantes sin registros
           }
       ],
       limit: sanitizedLimit,
       offset: offset,
       order: [["createdAt", "DESC"]],
       distinct: true
   });

   return {
       students: students.map((s) => StudentMapperInfra.toDomain(s)),
       studentsRaw: students,  // ← NUEVO: Datos raw con registers
       totalItems
   };
}

4. DomainStudentRepository.ts

Ubicación: src/domain/repositories/DomainStudentRepository.ts

Cambio: Actualizada la interfaz para incluir studentsRaw opcional

getPaginatedStudents(
  page: number, 
  limit: number, 
  search?: string, 
  statuses?: string[], 
  subjectId?: number, 
  schoolCourseId?: number
): Promise<{ students: Student[]; studentsRaw?: any[]; totalItems: number }>;  // ← studentsRaw agregado

5. student.controller.ts

Ubicación: src/infrastructure/controllers/student.controller.ts

Cambio: Calcular la propiedad renews en el controlador basándose en studentsRaw

getAdministrativeStudents = catchAsync(async (req: Request, res: Response) => {
    // ... código de parseo de parámetros ...

    const response = await this.getPaginatedStudentsUseCase.execute({
        page,
        limit,
        search,
        statuses,
        subjectId,
        schoolCourseId,
    });

    // ← NUEVO: Calcular renews basado en si tiene registros en el curso actual
    const dataWithRenews = response.students.map((student, index) => {
        const studentRaw = response.studentsRaw?.[index];
        const renews = studentRaw?.registers && studentRaw.registers.length > 0;
        return {
            ...student.toJSON(),
            renews  // ← NUEVO: Propiedad agregada
        };
    });

    res.status(200).json({
        data: dataWithRenews,  // ← ACTUALIZADO
        meta: {
            totalItems: response.totalItems,
            totalPages: response.totalPages,
            currentPage: response.currentPage,
            pageSize: response.pageSize,
        },
    });
});

Resultado Final

Respuesta del endpoint antes:

{
  "data": [
    {
      "id": 6,
      "name": "Teresa",
      "lastname": "Merino Bobillo",
      "status": "active"
      // ... otros campos
    }
  ]
}

Respuesta del endpoint después:

{
  "data": [
    {
      "id": 795,
      "name": "Teresa",
      "lastname": "Barazal Díaz",
      "status": "restricted",
      "renews": false  // ← NUEVO: No tiene registros en el curso actual
    },
    {
      "id": 6,
      "name": "Teresa",
      "lastname": "Merino Bobillo",
      "status": "active",
      "renews": true  // ← NUEVO: Tiene registros en el curso actual
    }
  ]
}

Decisiones de Diseño

✅ Por qué agregamos studentsRaw en lugar de modificar el mapper

Problema: El mapper StudentMapperInfra.toDomain() no incluye los registers porque mapea a la entidad de dominio Student, que no tiene esa propiedad.

Opciones consideradas:

  1. Modificar la entidad de dominio Student - No apropiado, ya que registers es contexto específico de una consulta
  2. Modificar el mapper para incluir registers - Afectaría otros casos de uso que no necesitan esta info
  3. Agregar studentsRaw opcional al return - Solución óptima porque:
  4. No rompe compatibilidad (propiedad opcional)
  5. No afecta otros casos de uso
  6. Permite al controlador calcular propiedades contextuales

✅ Por qué calculamos renews en el controlador

La propiedad renews es específica del contexto del endpoint administrativo. Otros casos de uso que llamen a getPaginatedStudents pueden no necesitar esta propiedad, por lo que es mejor calcularla en el controlador específico.


Archivos Modificados

  1. src/application/use-cases/student/GetPaginatedStudentsUseCase.ts
  2. src/app.ts
  3. src/infrastructure/adapters/persistence/StudentRepositoryImpl.ts
  4. src/domain/repositories/DomainStudentRepository.ts
  5. src/infrastructure/controllers/student.controller.ts

Testing Recomendado

# 1. Verificar compilación
npm run rebuild

# 2. Probar endpoint con diferentes escenarios:

# Caso 1: Estudiantes que renuevan (tienen registros)
GET /api/students/administrative?page=1&limit=20

# Caso 2: Filtrar por estado
GET /api/students/administrative?status=active&page=1&limit=20

# Caso 3: Búsqueda por nombre
GET /api/students/administrative?search=Teresa&page=1&limit=20

Notas Adicionales

  • ⚠️ Importante: Esta implementación respeta el patrón del proyecto de obtener el último curso en el caso de uso
  • Compatibilidad: Los cambios son retrocompatibles - studentsRaw es opcional
  • 📝 Mantenibilidad: El código está documentado y sigue las convenciones del proyecto
  • 🔄 Escalabilidad: Fácil agregar más propiedades calculadas en el futuro siguiendo el mismo patrón

Referencias

  • Repositorio: danielcasta0398/backend-musica-maestro
  • Curso Escolar Repository: SchoolCourseRepositoryImpl implementa getLastCourse()
  • Util existente: src/utils/getLastCourse.ts (consulta directa a BD, no usado en el patrón del dominio)