From db70462490a06ae8f22f24c392a5ff989cc93620 Mon Sep 17 00:00:00 2001 From: Artur Poffo Date: Sat, 24 Feb 2024 03:06:33 -0300 Subject: [PATCH] Feat(Enrollment, Course, Route): Get student progress in a course route --- README.md | 2 +- .../get-instructor-with-courses.spec.ts | 2 +- .../use-cases/get-student-progress.spec.ts | 113 +++++++++++++++++ .../use-cases/get-student-progress.ts | 120 ++++++++++++++++++ .../get-student-with-courses.spec.ts | 2 +- .../use-cases/issue-certificate.ts | 3 +- .../dtos/class-with-student-progress.ts | 6 + .../enterprise/entities/dtos/class.ts | 10 ++ .../complete-course-with-student-progress.ts | 9 -- .../entities/dtos/mappers/class-dto-mapper.ts | 15 +++ .../dtos/mappers/module-dto-mapper.ts | 14 ++ ...odule-with-classes-and-student-progress.ts | 15 --- .../dtos/module-with-student-progress.ts | 6 + .../http/controllers/get-student-progress.ts | 43 +++++++ .../class-with-student-progress-presenter.ts | 15 +++ .../module-with-student-progress-presenter.ts | 14 ++ src/infra/http/routes/enrollment.ts | 2 + .../make-get-student-progress-use-case.ts | 21 +++ 18 files changed, 383 insertions(+), 29 deletions(-) create mode 100644 src/domain/course-management/application/use-cases/get-student-progress.spec.ts create mode 100644 src/domain/course-management/application/use-cases/get-student-progress.ts create mode 100644 src/domain/course-management/enterprise/entities/dtos/class-with-student-progress.ts create mode 100644 src/domain/course-management/enterprise/entities/dtos/class.ts delete mode 100644 src/domain/course-management/enterprise/entities/dtos/complete-course-with-student-progress.ts create mode 100644 src/domain/course-management/enterprise/entities/dtos/mappers/class-dto-mapper.ts create mode 100644 src/domain/course-management/enterprise/entities/dtos/mappers/module-dto-mapper.ts delete mode 100644 src/domain/course-management/enterprise/entities/dtos/module-with-classes-and-student-progress.ts create mode 100644 src/domain/course-management/enterprise/entities/dtos/module-with-student-progress.ts create mode 100644 src/infra/http/controllers/get-student-progress.ts create mode 100644 src/infra/http/presenters/class-with-student-progress-presenter.ts create mode 100644 src/infra/http/presenters/module-with-student-progress-presenter.ts create mode 100644 src/infra/use-cases/factories/make-get-student-progress-use-case.ts diff --git a/README.md b/README.md index fd8d6bf..0c6be0a 100644 --- a/README.md +++ b/README.md @@ -248,7 +248,7 @@ - [x] POST /enrollments/:enrollmentId/modules/:moduleId/complete - Mark module as completed - [x] POST /enrollments/:enrollmentId/classes/:classId/complete - Mark class as completed - [x] POST /enrollments/:enrollmentId/complete - Mark enrollment as completed -- [ ] GET /enrollments/:enrollmentId/progress - Get student enrollment progress +- [x] GET /enrollments/:enrollmentId/progress - Get student enrollment progress - [x] GET /courses/:courseId/students/:studentId/enrollments - Get enrollment of a student on a course - [x] GET /enrollments/:enrollmentId/classes/completed - Fetch enrollment completed classes - [x] GET /enrollments/:enrollmentId/modules/completed - Fetch enrollment completed modules diff --git a/src/domain/course-management/application/use-cases/get-instructor-with-courses.spec.ts b/src/domain/course-management/application/use-cases/get-instructor-with-courses.spec.ts index 609edb9..0902f27 100644 --- a/src/domain/course-management/application/use-cases/get-instructor-with-courses.spec.ts +++ b/src/domain/course-management/application/use-cases/get-instructor-with-courses.spec.ts @@ -21,7 +21,7 @@ let inMemoryModulesRepository: InMemoryModulesRepository let inMemoryCoursesRepository: InMemoryCoursesRepository let sut: GetInstructorWithCoursesUseCase -describe('Get instructors with their courses', () => { +describe('Get instructors with their courses use case', () => { beforeEach(() => { inMemoryEnrollmentCompletedItemsRepository = new InMemoryEnrollmentCompletedItemsRepository() inMemoryClassesRepository = new InMemoryClassesRepository() diff --git a/src/domain/course-management/application/use-cases/get-student-progress.spec.ts b/src/domain/course-management/application/use-cases/get-student-progress.spec.ts new file mode 100644 index 0000000..7e5416a --- /dev/null +++ b/src/domain/course-management/application/use-cases/get-student-progress.spec.ts @@ -0,0 +1,113 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeClass } from '../../../../../test/factories/make-class' +import { makeCourse } from '../../../../../test/factories/make-course' +import { makeEnrollment } from '../../../../../test/factories/make-enrollment' +import { makeEnrollmentCompletedItem } from '../../../../../test/factories/make-enrollment-completed-item' +import { makeInstructor } from '../../../../../test/factories/make-instructor' +import { makeModule } from '../../../../../test/factories/make-module' +import { makeStudent } from '../../../../../test/factories/make-student' +import { InMemoryClassesRepository } from '../../../../../test/repositories/in-memory-classes-repository' +import { InMemoryCourseTagsRepository } from '../../../../../test/repositories/in-memory-course-tags-repository' +import { InMemoryCoursesRepository } from '../../../../../test/repositories/in-memory-courses-repository' +import { InMemoryEnrollmentCompletedItemsRepository } from '../../../../../test/repositories/in-memory-enrollment-completed-items-repository' +import { InMemoryEnrollmentsRepository } from '../../../../../test/repositories/in-memory-enrollments-repository' +import { InMemoryInstructorRepository } from '../../../../../test/repositories/in-memory-instructors-repository' +import { InMemoryModulesRepository } from '../../../../../test/repositories/in-memory-modules-repository' +import { InMemoryStudentsRepository } from '../../../../../test/repositories/in-memory-students-repository' +import { GetStudentProgressUseCase } from './get-student-progress' + +let inMemoryEnrollmentCompletedItemsRepository: InMemoryEnrollmentCompletedItemsRepository +let inMemoryEnrollmentsRepository: InMemoryEnrollmentsRepository +let inMemoryCourseTagsRepository: InMemoryCourseTagsRepository +let inMemoryStudentsRepository: InMemoryStudentsRepository +let inMemoryClassesRepository: InMemoryClassesRepository +let inMemoryInstructorsRepository: InMemoryInstructorRepository +let inMemoryModulesRepository: InMemoryModulesRepository +let inMemoryCoursesRepository: InMemoryCoursesRepository +let sut: GetStudentProgressUseCase + +describe('Get student progress use case', () => { + beforeEach(() => { + inMemoryEnrollmentCompletedItemsRepository = new InMemoryEnrollmentCompletedItemsRepository() + inMemoryClassesRepository = new InMemoryClassesRepository() + inMemoryCourseTagsRepository = new InMemoryCourseTagsRepository() + inMemoryInstructorsRepository = new InMemoryInstructorRepository() + inMemoryStudentsRepository = new InMemoryStudentsRepository() + + inMemoryModulesRepository = new InMemoryModulesRepository(inMemoryClassesRepository) + + inMemoryEnrollmentsRepository = new InMemoryEnrollmentsRepository( + inMemoryStudentsRepository, inMemoryEnrollmentCompletedItemsRepository + ) + inMemoryCoursesRepository = new InMemoryCoursesRepository(inMemoryModulesRepository, inMemoryInstructorsRepository, inMemoryEnrollmentsRepository, inMemoryStudentsRepository, inMemoryCourseTagsRepository) + + sut = new GetStudentProgressUseCase( + inMemoryEnrollmentsRepository, + inMemoryCoursesRepository, + inMemoryModulesRepository, + inMemoryEnrollmentCompletedItemsRepository + ) + }) + + it('should be able to get student progress in a course', async () => { + const instructor = makeInstructor() + await inMemoryInstructorsRepository.create(instructor) + + const course = makeCourse({ name: 'First Course', instructorId: instructor.id }) + await inMemoryCoursesRepository.create(course) + + const module = makeModule({ + name: 'John Doe Module', + courseId: course.id, + moduleNumber: 1 + }) + await inMemoryModulesRepository.create(module) + + const classToAdd = makeClass({ name: 'John Doe Class', moduleId: module.id, classNumber: 1 }) + await inMemoryClassesRepository.create(classToAdd) + + const student = makeStudent() + await inMemoryStudentsRepository.create(student) + + const enrollment = makeEnrollment({ studentId: student.id, courseId: course.id }) + await inMemoryEnrollmentsRepository.create(enrollment) + + const completedItem = makeEnrollmentCompletedItem({ enrollmentId: enrollment.id, itemId: classToAdd.id, type: 'CLASS' }) + await inMemoryEnrollmentCompletedItemsRepository.create(completedItem) + + const result = await sut.exec({ + enrollmentId: enrollment.id.toString(), + studentId: student.id.toString() + }) + + expect(result.isRight()).toBe(true) + expect(result.value).toMatchObject({ + classes: expect.arrayContaining([ + expect.objectContaining({ + class: expect.objectContaining({ + name: 'John Doe Class' + }), + completed: true + }) + ]), + modules: expect.arrayContaining([ + expect.objectContaining({ + module: expect.objectContaining({ + name: 'John Doe Module' + }), + completed: false + }) + ]) + }) + }) + + it('should not be able to get a student progress from a inexistent enrollment', async () => { + const result = await sut.exec({ + enrollmentId: 'inexistentEnrollmentId', + studentId: 'inexistentStudentId' + }) + + expect(result.isLeft()).toBe(true) + expect(result.value).toBeInstanceOf(ResourceNotFoundError) + }) +}) diff --git a/src/domain/course-management/application/use-cases/get-student-progress.ts b/src/domain/course-management/application/use-cases/get-student-progress.ts new file mode 100644 index 0000000..508a01a --- /dev/null +++ b/src/domain/course-management/application/use-cases/get-student-progress.ts @@ -0,0 +1,120 @@ +import { left, right, type Either } from '@/core/either' +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type UseCase } from '@/core/use-cases/use-case' +import { type ClassWithStudentProgressDTO } from '../../enterprise/entities/dtos/class-with-student-progress' +import { ClassDtoMapper } from '../../enterprise/entities/dtos/mappers/class-dto-mapper' +import { ModuleDtoMapper } from '../../enterprise/entities/dtos/mappers/module-dto-mapper' +import { type ModuleWithStudentProgressDTO } from '../../enterprise/entities/dtos/module-with-student-progress' +import { type CoursesRepository } from '../repositories/courses-repository' +import { type EnrollmentCompletedItemsRepository } from '../repositories/enrollment-completed-items-repository' +import { type EnrollmentsRepository } from '../repositories/enrollments-repository' +import { type ModulesRepository } from '../repositories/modules-repository' + +interface GetStudentProgressUseCaseRequest { + enrollmentId: string + studentId: string +} + +type GetStudentProgressUseCaseResponse = Either< +ResourceNotFoundError | NotAllowedError, +{ + classes: ClassWithStudentProgressDTO[] + modules: ModuleWithStudentProgressDTO[] +} +> + +export class GetStudentProgressUseCase implements UseCase { + constructor( + private readonly enrollmentsRepository: EnrollmentsRepository, + private readonly coursesRepository: CoursesRepository, + private readonly modulesRepository: ModulesRepository, + private readonly enrollmentCompletedItemsRepository: EnrollmentCompletedItemsRepository + ) { } + + async exec({ + enrollmentId, + studentId + }: GetStudentProgressUseCaseRequest): Promise { + const enrollment = await this.enrollmentsRepository.findById(enrollmentId) + + if (!enrollment) { + return left(new ResourceNotFoundError()) + } + + const studentIsTheEnrollmentOwner = enrollment.studentId.toString() === studentId + + if (!studentIsTheEnrollmentOwner) { + return left(new NotAllowedError()) + } + + const course = await this.coursesRepository.findById(enrollment.courseId.toString()) + + if (!course) { + return left(new ResourceNotFoundError()) + } + + const courseClasses = await this.modulesRepository.findManyClassesByCourseId(course.id.toString()) + + const completedClasses = await this.enrollmentCompletedItemsRepository.findManyCompletedClassesByEnrollmentId( + enrollmentId + ) + const completedClassIds = completedClasses.map(completedClass => completedClass.itemId.toString()) + + const classesProgression: ClassWithStudentProgressDTO[] = [] + + courseClasses.forEach(courseClass => { + const isClassCompleted = completedClassIds.includes(courseClass.id.toString()) + + if (isClassCompleted) { + const classWithProgress: ClassWithStudentProgressDTO = { + class: ClassDtoMapper.toDTO(courseClass), + completed: true + } + + classesProgression.push(classWithProgress) + } else { + const classWithProgress: ClassWithStudentProgressDTO = { + class: ClassDtoMapper.toDTO(courseClass), + completed: false + } + + classesProgression.push(classWithProgress) + } + }) + + const courseModules = await this.modulesRepository.findManyByCourseId(course.id.toString()) + + const completedModules = await this.enrollmentCompletedItemsRepository.findManyCompletedModulesByEnrollmentId( + enrollmentId + ) + const completedModuleIds = completedModules.map(completedModule => completedModule.id.toString()) + + const modulesProgression: ModuleWithStudentProgressDTO[] = [] + + courseModules.forEach(courseModule => { + const isModuleCompleted = completedModuleIds.includes(courseModule.id.toString()) + + if (isModuleCompleted) { + const moduleWithProgress: ModuleWithStudentProgressDTO = { + module: ModuleDtoMapper.toDTO(courseModule), + completed: true + } + + modulesProgression.push(moduleWithProgress) + } else { + const moduleWithProgress: ModuleWithStudentProgressDTO = { + module: ModuleDtoMapper.toDTO(courseModule), + completed: false + } + + modulesProgression.push(moduleWithProgress) + } + }) + + return right({ + classes: classesProgression, + modules: modulesProgression + }) + } +} diff --git a/src/domain/course-management/application/use-cases/get-student-with-courses.spec.ts b/src/domain/course-management/application/use-cases/get-student-with-courses.spec.ts index 895c2fa..1fd70e4 100644 --- a/src/domain/course-management/application/use-cases/get-student-with-courses.spec.ts +++ b/src/domain/course-management/application/use-cases/get-student-with-courses.spec.ts @@ -23,7 +23,7 @@ let inMemoryModulesRepository: InMemoryModulesRepository let inMemoryCoursesRepository: InMemoryCoursesRepository let sut: GetStudentWithCoursesUseCase -describe('Get student with their courses', () => { +describe('Get student with their courses use case', () => { beforeEach(() => { inMemoryEnrollmentCompletedItemsRepository = new InMemoryEnrollmentCompletedItemsRepository() inMemoryClassesRepository = new InMemoryClassesRepository() diff --git a/src/domain/course-management/application/use-cases/issue-certificate.ts b/src/domain/course-management/application/use-cases/issue-certificate.ts index d938802..7d82e71 100644 --- a/src/domain/course-management/application/use-cases/issue-certificate.ts +++ b/src/domain/course-management/application/use-cases/issue-certificate.ts @@ -57,8 +57,7 @@ export class IssueCertificateUseCase implements UseCase ClassWithStudentProgressPresenter.toHTTP(classWithProgress)), + modules: modules.map(moduleWithProgress => ModuleWithStudentProgressPresenter.toHTTP(moduleWithProgress)) + }) +} diff --git a/src/infra/http/presenters/class-with-student-progress-presenter.ts b/src/infra/http/presenters/class-with-student-progress-presenter.ts new file mode 100644 index 0000000..e39fff9 --- /dev/null +++ b/src/infra/http/presenters/class-with-student-progress-presenter.ts @@ -0,0 +1,15 @@ +import { type ClassWithStudentProgressDTO } from '@/domain/course-management/enterprise/entities/dtos/class-with-student-progress' + +export class ClassWithStudentProgressPresenter { + static toHTTP(classWithStudentProgress: ClassWithStudentProgressDTO) { + return { + id: classWithStudentProgress.class.id.toString(), + name: classWithStudentProgress.class.name, + description: classWithStudentProgress.class.description, + classNumber: classWithStudentProgress.class.classNumber, + completed: classWithStudentProgress.completed, + moduleId: classWithStudentProgress.class.moduleId.toString(), + videoId: classWithStudentProgress.class.videoId.toString() + } + } +} diff --git a/src/infra/http/presenters/module-with-student-progress-presenter.ts b/src/infra/http/presenters/module-with-student-progress-presenter.ts new file mode 100644 index 0000000..7aee3aa --- /dev/null +++ b/src/infra/http/presenters/module-with-student-progress-presenter.ts @@ -0,0 +1,14 @@ +import { type ModuleWithStudentProgressDTO } from '@/domain/course-management/enterprise/entities/dtos/module-with-student-progress' + +export class ModuleWithStudentProgressPresenter { + static toHTTP(moduleWithStudentProgress: ModuleWithStudentProgressDTO) { + return { + id: moduleWithStudentProgress.module.id.toString(), + name: moduleWithStudentProgress.module.name, + description: moduleWithStudentProgress.module.description, + moduleNumber: moduleWithStudentProgress.module.moduleNumber, + completed: moduleWithStudentProgress.completed, + courseId: moduleWithStudentProgress.module.courseId.toString() + } + } +} diff --git a/src/infra/http/routes/enrollment.ts b/src/infra/http/routes/enrollment.ts index 6521e8f..3b80893 100644 --- a/src/infra/http/routes/enrollment.ts +++ b/src/infra/http/routes/enrollment.ts @@ -4,6 +4,7 @@ import { enrollToCourseController } from '../controllers/enroll-to-course' import { fetchEnrollmentCompletedClassesController } from '../controllers/fetch-enrollment-completed-classes' import { fetchEnrollmentCompletedModulesController } from '../controllers/fetch-enrollment-completed-modules' import { getEnrollmentDetailsController } from '../controllers/get-enrollment-details' +import { getStudentProgressController } from '../controllers/get-student-progress' import { markCourseAsCompletedController } from '../controllers/mark-course-as-completed' import { toggleMarkClassAsCompletedController } from '../controllers/toggle-mark-class-as-completed' import { toggleMarkModuleAsCompletedController } from '../controllers/toggle-mark-module-as-completed' @@ -14,6 +15,7 @@ export async function enrollmentRoutes(app: FastifyInstance) { app.get('/courses/:courseId/students/:studentId/enrollments', { onRequest: [verifyJwt] }, getEnrollmentDetailsController) app.get('/enrollments/:enrollmentId/classes/completed', { onRequest: [verifyJwt] }, fetchEnrollmentCompletedClassesController) app.get('/enrollments/:enrollmentId/modules/completed', { onRequest: [verifyJwt] }, fetchEnrollmentCompletedModulesController) + app.get('/enrollments/:enrollmentId/progress', { onRequest: [verifyJwt] }, getStudentProgressController) app.post('/courses/:courseId/enroll', { onRequest: [verifyJwt, verifyUserRole('STUDENT')] }, enrollToCourseController) app.post('/enrollments/:enrollmentId/classes/:classId/completed', { onRequest: [verifyJwt, verifyUserRole('STUDENT')] }, toggleMarkClassAsCompletedController) diff --git a/src/infra/use-cases/factories/make-get-student-progress-use-case.ts b/src/infra/use-cases/factories/make-get-student-progress-use-case.ts new file mode 100644 index 0000000..2c68539 --- /dev/null +++ b/src/infra/use-cases/factories/make-get-student-progress-use-case.ts @@ -0,0 +1,21 @@ +import { GetStudentProgressUseCase } from '@/domain/course-management/application/use-cases/get-student-progress' +import { makePrismaCoursesRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-courses-repository' +import { makePrismaEnrollmentsRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-enrollments-repository' +import { makePrismaModulesRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-modules-repository' +import { PrismaEnrollmentCompleteItemsRepository } from '@/infra/database/prisma/repositories/prisma-enrollment-completed-items-repository' + +export function makeGetStudentProgressUseCase() { + const prismaEnrollmentsRepository = makePrismaEnrollmentsRepository() + const prismaCoursesRepository = makePrismaCoursesRepository() + const prismaModulesRepository = makePrismaModulesRepository() + const prismaEnrollmentCompleteItemsRepository = new PrismaEnrollmentCompleteItemsRepository() + + const getStudentProgressUseCase = new GetStudentProgressUseCase( + prismaEnrollmentsRepository, + prismaCoursesRepository, + prismaModulesRepository, + prismaEnrollmentCompleteItemsRepository + ) + + return getStudentProgressUseCase +}