diff --git a/.eslintrc.json b/.eslintrc.json index 3751af1..0099a04 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -17,6 +17,8 @@ "@typescript-eslint/no-unused-expressions": "off", "no-new": "off", "no-self-assign": "off", - "@typescript-eslint/no-unsafe-argument": "off" + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-floating-promises": "off", + "no-constant-condition": "off" } } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..4aafe9e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "cSpell.words": [ + "accesstoken", + "authtoken", + "codespark", + "dtos", + "fastify", + "originalname" + ], + "CodeGPT.apiKey": "CodeGPT Plus Beta" +} \ No newline at end of file diff --git a/README.md b/README.md index 7762bd7..0c6be0a 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ - [x] CRUDs for all main entities: course, module, class, user, etc. - [ ] Return information about a course with student progress in your modules and classes. -- [ ] Video streaming to watch the classes. +- [x] Video streaming to watch the classes. ## Business Rules @@ -59,11 +59,11 @@ ## Non-Functional Requirements -- [ ] File upload/storage on Cloudflare R2. +- [x] File upload/storage on Cloudflare R2. - [x] User's password must be encrypted. - [x] Application data must be persisted in a PostgreSQL database with Docker. -- [ ] User must be identified by JWT. -- [ ] JWT must use the RS256 algorithm. +- [x] User must be identified by JWT. +- [x] JWT must use the RS256 algorithm. ## Initial Entities (Domain) @@ -182,78 +182,85 @@ ## Initial Routes (must have changes) ### Users -- [ ] GET /users/:userId - Get user details -- [ ] POST /users - Register user -- [ ] PUT /users/:userId - Update user -- [ ] DELETE /users/:userId - Delete user +- [x] GET /users/:userId - Get user details +- [x] POST /users - Register user +- [x] PUT /users/:userId - Update user +- [x] DELETE /users/:userId - Delete user ### Sessions -- [ ] POST /sessions - User authentication +- [x] POST /sessions - User authentication ### Students -- [ ] GET /courses/:courseId/students - Get students enrolled in course -- [ ] GET /students/:studentId/enrollments - Get student courses with instructor and evaluations +- [x] GET /courses/:courseId/students - Get students enrolled in course +- [x] GET /students/:studentId/enrollments - Get student courses with instructor and evaluations ### Instructors -- [ ] GET /courses/:courseId/instructor - Get course instructor details -- [ ] GET /instructors/:instructorId/courses - Get instructor courses with instructor and evaluations +- [x] GET /courses/:courseId/instructors - Get course instructor details +- [x] GET /instructors/:instructorId/courses - Get instructor courses with instructor and evaluations ### Courses -- [ ] GET /courses/:courseId - Get course details -- [ ] GET /courses/:courseId/enrollments/:enrollmentId/progress - Get course details with student progress -- [ ] GET /courses/:courseId/stats - Get course statistics, like duration and number of classes -- [ ] GET /courses/:courseId/metrics - Get course metrics for a dashboard -- [ ] GET /courses - Get recent courses with instructor and evaluation average -- [ ] GET /courses/filter - Filter courses by name or tags -- [ ] POST /courses - Register course -- [ ] PUT /courses/:courseId - Update course details -- [ ] DELETE /courses/:courseId - Delete course +- [x] GET /courses/:courseId - Get course details +- [x] GET /courses/:courseId/stats - Get course statistics, like duration and number of classes +- [x] GET /courses/:courseId/metrics - Get course metrics for a dashboard +- [x] GET /courses - Get recent courses with instructor and evaluation average +- [x] GET /courses/filter/name?q="" - Filter courses by name +- [x] GET /courses/filter/tags?q="" - Filter courses by tags +- [x] POST /courses - Register course +- [x] PUT /courses/:courseId - Update course details +- [x] DELETE /courses/:courseId - Delete course ### Certificate -- [ ] POST /courses/:courseId/certificates - Add certificate to course -- [ ] DELETE /courses/:courseId/certificates - Remove certificate from course +- [x] POST /courses/:courseId/certificates - Add certificate to course +- [x] DELETE /courses/:courseId/certificates - Remove certificate from course ### StudentCertificate -- [ ] GET /enrollments/:enrollmentId/certificate - Issue student certificate +- [x] POST /enrollments/:enrollmentId/certificates/issue - Issue student certificate ### Modules -- [ ] GET /courses/:courseId/modules - Get course modules -- [ ] POST /modules - Register module -- [ ] GET /modules/:moduleId/classes - Get classes from a module -- [ ] PUT /modules/:moduleId - Update module details -- [ ] DELETE /modules/:moduleId - Delete module +- [x] GET /courses/:courseId/modules - Get course modules +- [x] POST /modules - Register module +- [x] GET /modules/:moduleId/classes - Get classes from a module +- [x] PUT /modules/:moduleId - Update module details +- [x] DELETE /modules/:moduleId - Delete module ### Classes -- [ ] GET /courses/:courseId/classes - Get course classes -- [ ] POST /classes - Register class -- [ ] PUT /classes/:classId - Update class details -- [ ] DELETE /classes/:classId - Delete class +- [x] GET /courses/:courseId/classes - Get course classes +- [x] POST /modules/:moduleId/classes/video/:videoId - Register class +- [x] PUT /classes/:classId - Update class details +- [x] DELETE /classes/:classId - Delete class ### Tags -- [ ] GET /tags - Get recent tags -- [ ] POST /tags - Register tag +- [x] GET /tags - Get recent tags +- [x] POST /tags - Register tag ### CourseTags -- [ ] GET /courses/:courseId/tags - Get course tags -- [ ] POST /courses/:courseId/tags/:tagId - Attach tag to course -- [ ] POST /courses/:courseId/tags/tag:id - Remove tag to course +- [x] GET /courses/:courseId/tags - Get course tags +- [x] POST /courses/:courseId/tags/:tagId - Attach tag to course +- [x] POST /courses/:courseId/tags/:tagId - Remove tag to course ### Evaluations -- [ ] GET /courses/:courseId/evaluation - Get course evaluation average -- [ ] POST /evaluations - Register evaluation -- [ ] PUT /evaluations/:evaluationId - Update evaluation +- [x] GET /courses/:courseId/evaluations/average - Get course evaluation average +- [x] POST /evaluations - Register evaluation +- [x] PUT /evaluations/:evaluationId - Update evaluation ### Enrollments -- [ ] POST /enrollments - Register enrollment -- [ ] GET /enrollments/students/:studentId/courses/:courseId - Get enrollment of a student on a course -- [ ] DELETE /enrollments/students/:studentId/courses/:courseId - Cancel enrollment +- [x] POST /courses/:courseId/enroll - Enroll to course +- [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 +- [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 +- [x] DELETE /enrollments/:enrollmentId - Cancel enrollment ### Video -- [ ] POST /videos - Upload video -- [ ] GET /videos/:videoId/stream - Video streaming +- [x] POST /videos - Upload video +- [x] GET /videos/:videoId - Get video details ### Image -- [ ] POST /images - Upload image +- [x] POST /images - Upload image +- [x] GET /images/:imageId - Get image details ## Potential Refactoring or Updates: @@ -271,4 +278,8 @@ - [x] Fix infinite calls to Prisma repositories in some mapper usage scenarios. - [x] Introduce domain events for Prisma repositories. - [ ] Implement mappers for mapping domain entities to DTOs. -- [ ] Implement pagination. \ No newline at end of file +- [ ] Implement pagination. +- [ ] Implement E2E tests. +- [ ] Implement register user validations, like: email and cpf. - Could it be a value object? +- [ ] Refactor error handling in controllers +- [ ] Refactor the event handler class instance \ No newline at end of file diff --git a/package.json b/package.json index 1dd1a5c..b6863ef 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "prisma:init": "prisma migrate deploy && prisma generate", "test:unit": "vitest run", "test:unit:watch": "vitest", - "test:e2e": "vitest run --config ./vitest.config.e2e.ts", - "test:e2e:watch": "vitest --config ./vitest.config.e2e.ts", + "test:e2e": "vitest run --config ./vitest.config.e2e.mts", + "test:e2e:watch": "vitest --config ./vitest.config.e2e.mts", "test:cov": "vitest run --coverage", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix" }, @@ -23,15 +23,23 @@ "author": "Artur Poffo", "license": "ISC", "dependencies": { - "@prisma/client": "^5.9.1", + "@aws-sdk/client-s3": "^3.515.0", + "@fastify/cookie": "^9.3.1", + "@fastify/cors": "^9.0.1", + "@fastify/jwt": "^8.0.0", + "@prisma/client": "^5.10.2", "bcryptjs": "^2.4.3", + "buffer-to-stream": "^1.0.0", "dotenv": "^16.3.1", "fastify": "^4.25.2", + "fastify-multer": "^2.0.3", + "get-video-duration": "^4.1.0", "zod": "^3.22.4" }, "devDependencies": { "@faker-js/faker": "^8.3.1", "@types/bcryptjs": "^2.4.6", + "@types/buffer-to-stream": "^1.0.3", "@types/node": "^20.11.5", "@typescript-eslint/eslint-plugin": "^6.4.0", "eslint": "^8.0.1", @@ -39,11 +47,12 @@ "eslint-plugin-import": "^2.25.2", "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", "eslint-plugin-promise": "^6.0.0", - "prisma": "^5.9.1", + "prisma": "^5.10.2", + "supertest": "^6.3.4", "tsup": "^8.0.1", "tsx": "^4.7.0", "typescript": "*", "vite-tsconfig-paths": "^4.3.1", "vitest": "^1.2.1" } -} +} \ No newline at end of file diff --git a/prisma/migrations/20240222184213_turn_video_file_key_optional/migration.sql b/prisma/migrations/20240222184213_turn_video_file_key_optional/migration.sql new file mode 100644 index 0000000..703c42d --- /dev/null +++ b/prisma/migrations/20240222184213_turn_video_file_key_optional/migration.sql @@ -0,0 +1,8 @@ +-- DropForeignKey +ALTER TABLE "videos" DROP CONSTRAINT "videos_file_key_fkey"; + +-- AlterTable +ALTER TABLE "videos" ALTER COLUMN "file_key" DROP NOT NULL; + +-- AddForeignKey +ALTER TABLE "videos" ADD CONSTRAINT "videos_file_key_fkey" FOREIGN KEY ("file_key") REFERENCES "files"("key") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20240222230341_turn_file_key_optional/migration.sql b/prisma/migrations/20240222230341_turn_file_key_optional/migration.sql new file mode 100644 index 0000000..c599d6a --- /dev/null +++ b/prisma/migrations/20240222230341_turn_file_key_optional/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "files" ALTER COLUMN "key" DROP NOT NULL; diff --git a/prisma/migrations/20240222232146_create_image_model/migration.sql b/prisma/migrations/20240222232146_create_image_model/migration.sql new file mode 100644 index 0000000..7cdef23 --- /dev/null +++ b/prisma/migrations/20240222232146_create_image_model/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE "images" ( + "id" TEXT NOT NULL, + "file_key" TEXT, + + CONSTRAINT "images_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "images_file_key_key" ON "images"("file_key"); + +-- AddForeignKey +ALTER TABLE "images" ADD CONSTRAINT "images_file_key_fkey" FOREIGN KEY ("file_key") REFERENCES "files"("key") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20240222232304_reset_file_key_optional/migration.sql b/prisma/migrations/20240222232304_reset_file_key_optional/migration.sql new file mode 100644 index 0000000..ddad99c --- /dev/null +++ b/prisma/migrations/20240222232304_reset_file_key_optional/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - Made the column `key` on table `files` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "files" ALTER COLUMN "key" SET NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4afbaab..ee34dd2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -178,7 +178,9 @@ model File { key String @unique() size Decimal storedAt DateTime @default(now()) @map("stored_at") - video Video? + + video Video? + Image Image? @@map("files") } @@ -190,7 +192,16 @@ model Video { classes Class[] - fileKey String @unique() @map("file_key") + fileKey String? @unique() @map("file_key") @@map("videos") } + +model Image { + id String @id @default(uuid()) + file File? @relation(fields: [fileKey], references: [key]) + + fileKey String? @unique() @map("file_key") + + @@map("images") +} diff --git a/src/domain/storage/application/use-cases/errors/invalid-mime-type-error.ts b/src/core/errors/errors/invalid-mime-type-error.ts similarity index 100% rename from src/domain/storage/application/use-cases/errors/invalid-mime-type-error.ts rename to src/core/errors/errors/invalid-mime-type-error.ts diff --git a/src/domain/course-management/application/cryptography/encrypter.ts b/src/domain/course-management/application/cryptography/encrypter.ts index 626128f..896b1a9 100644 --- a/src/domain/course-management/application/cryptography/encrypter.ts +++ b/src/domain/course-management/application/cryptography/encrypter.ts @@ -1,3 +1,8 @@ +export interface EncrypterProps { + role: 'STUDENT' | 'INSTRUCTOR' + sub: string +} + export interface Encrypter { - encrypt: (payload: Record) => Promise + encrypt: (payload: EncrypterProps) => Promise } diff --git a/src/domain/course-management/application/repositories/enrollment-completed-items-repository.ts b/src/domain/course-management/application/repositories/enrollment-completed-items-repository.ts index e2d327f..aca4ca7 100644 --- a/src/domain/course-management/application/repositories/enrollment-completed-items-repository.ts +++ b/src/domain/course-management/application/repositories/enrollment-completed-items-repository.ts @@ -2,8 +2,10 @@ import { type EnrollmentCompletedItem } from '../../enterprise/entities/enrollme export interface EnrollmentCompletedItemsRepository { findById: (id: string) => Promise + findByEnrollmentIdAndItemId: (enrollmentId: string, itemId: string) => Promise findManyCompletedClassesByEnrollmentId: (enrollmentId: string) => Promise findManyCompletedModulesByEnrollmentId: (enrollmentId: string) => Promise findAllByEnrollmentId: (enrollmentId: string) => Promise create: (completedItem: EnrollmentCompletedItem) => Promise + delete: (completedItem: EnrollmentCompletedItem) => Promise } diff --git a/src/domain/course-management/application/repositories/enrollments-repository.ts b/src/domain/course-management/application/repositories/enrollments-repository.ts index 303ab42..bfd6992 100644 --- a/src/domain/course-management/application/repositories/enrollments-repository.ts +++ b/src/domain/course-management/application/repositories/enrollments-repository.ts @@ -7,7 +7,6 @@ export interface EnrollmentsRepository { findManyByCourseId: (courseId: string) => Promise findManyByStudentId: (studentId: string) => Promise findManyStudentsByCourseId: (courseId: string) => Promise - markItemAsCompleted: (completedItemId: string, enrollment: Enrollment) => Promise markAsCompleted: (enrollment: Enrollment) => Promise countEnrollmentsByYear: (year: number) => Promise create: (enrollment: Enrollment) => Promise diff --git a/src/domain/course-management/application/subscribers/on-file-uploaded.spec.ts b/src/domain/course-management/application/subscribers/on-file-uploaded.spec.ts new file mode 100644 index 0000000..dc79154 --- /dev/null +++ b/src/domain/course-management/application/subscribers/on-file-uploaded.spec.ts @@ -0,0 +1,57 @@ +import { OnFileUploaded } from '@/domain/course-management/application/subscribers/on-file-uploaded' +import { GetVideoDuration } from '@/infra/storage/utils/get-video-duration' +import { InMemoryVideosRepository } from '../../../../../test/repositories/in-memory-videos-repository' +import { waitFor } from '../../../../../test/utils/wait-for' +import { InMemoryFilesRepository } from './../../../../../test/repositories/in-memory-files-repository' +import { InMemoryImagesRepository } from './../../../../../test/repositories/in-memory-images-repository' +import { FakeUploader } from './../../../../../test/storage/fake-uploader' +import { UploadFileUseCase } from './../../../storage/application/use-cases/upload-file' + +let inMemoryFilesRepository: InMemoryFilesRepository +let inMemoryImagesRepository: InMemoryImagesRepository +let inMemoryVideosRepository: InMemoryVideosRepository +let fakeUploader: FakeUploader +let getVideoDuration: GetVideoDuration +let uploadFileUseCase: UploadFileUseCase + +describe('On file uploaded event', () => { + beforeEach(() => { + inMemoryFilesRepository = new InMemoryFilesRepository() + inMemoryImagesRepository = new InMemoryImagesRepository() + inMemoryVideosRepository = new InMemoryVideosRepository() + fakeUploader = new FakeUploader() + getVideoDuration = new GetVideoDuration() + uploadFileUseCase = new UploadFileUseCase( + inMemoryFilesRepository, + fakeUploader + ) + + new OnFileUploaded( + inMemoryImagesRepository, + inMemoryVideosRepository, + getVideoDuration + ) + }) + + it('should be able to upload a file and persist your respective entity, image or video', async () => { + const result = await uploadFileUseCase.exec({ + fileName: 'file.jpg', + body: Buffer.from('image body'), + size: 1024, + fileType: 'image/jpeg' + }) + + expect(result.isRight()).toBe(true) + expect(inMemoryFilesRepository.items[0]).toMatchObject({ + fileName: 'file.jpg' + }) + expect(fakeUploader.files[0]).toMatchObject({ + fileName: 'file.jpg' + }) + await waitFor(() => { + expect(inMemoryImagesRepository.items[0]).toMatchObject({ + imageName: 'file.jpg' + }) + }, 5000) + }) +}) diff --git a/src/domain/course-management/application/subscribers/on-file-uploaded.ts b/src/domain/course-management/application/subscribers/on-file-uploaded.ts new file mode 100644 index 0000000..5530aea --- /dev/null +++ b/src/domain/course-management/application/subscribers/on-file-uploaded.ts @@ -0,0 +1,54 @@ +import { DomainEvents } from '@/core/events/domain-events' +import { type EventHandler } from '@/core/events/event-handler' +import { FileUploadedEvent } from '@/domain/storage/enterprise/events/file-uploaded' +import { Image } from '../../enterprise/entities/image' +import { Video } from '../../enterprise/entities/video' +import { type ImagesRepository } from '../repositories/images-repository' +import { type VideosRepository } from '../repositories/videos-repository' +import { type GetVideoDuration } from './../../../../infra/storage/utils/get-video-duration' + +export class OnFileUploaded implements EventHandler { + constructor( + private readonly imagesRepository: ImagesRepository, + private readonly videosRepository: VideosRepository, + private readonly getVideoDuration: GetVideoDuration + ) { + this.setupSubscriptions() + } + + setupSubscriptions(): void { + DomainEvents.register( + this.createRespectiveEntity.bind(this) as (event: unknown) => void, + FileUploadedEvent.name + ) + } + + private async createRespectiveEntity({ file }: FileUploadedEvent) { + if (/image\/(jpeg|png)/.test(file.fileType)) { + const image = Image.create({ + body: file.body, + imageName: file.fileName, + size: file.size, + imageKey: file.fileKey, + imageType: file.fileType as 'image/jpeg' | 'image/png', + storedAt: file.storedAt + }) + + await this.imagesRepository.create(image) + } else if (/video\/(mp4|avi)/.test(file.fileType)) { + const duration = await this.getVideoDuration.getInSecondsByBuffer(file.body) + + const video = Video.create({ + body: file.body, + videoName: file.fileName, + size: file.size, + videoKey: file.fileKey, + videoType: file.fileType as 'video/mp4' | 'video/avi', + duration: Math.round(duration), + storedAt: file.storedAt + }) + + await this.videosRepository.create(video) + } + } +} diff --git a/src/domain/course-management/application/use-cases/authenticate-user.ts b/src/domain/course-management/application/use-cases/authenticate-user.ts index 3d4e6b2..15a7ac9 100644 --- a/src/domain/course-management/application/use-cases/authenticate-user.ts +++ b/src/domain/course-management/application/use-cases/authenticate-user.ts @@ -2,6 +2,7 @@ import { left, right, type Either } from '@/core/either' import { WrongCredentialsError } from '@/core/errors/errors/wrong-credentials-error' import { type UseCase } from '@/core/use-cases/use-case' import { type HashComparer } from '@/domain/course-management/application/cryptography/hash-comparer' +import { Student } from '../../enterprise/entities/student' import { type Encrypter } from './../cryptography/encrypter' import { type UsersRepository } from './../repositories/users-repository' @@ -40,8 +41,11 @@ export class AuthenticateUserUseCase implements UseCase { name: 'New name' }) }) + expect(inMemoryCoursesRepository.items[0]).toMatchObject({ + name: 'New name' + }) }) it('should not be able to edit course details of a inexistent course', async () => { diff --git a/src/domain/course-management/application/use-cases/edit-course-details.ts b/src/domain/course-management/application/use-cases/edit-course-details.ts index 474aabd..fb1e1ca 100644 --- a/src/domain/course-management/application/use-cases/edit-course-details.ts +++ b/src/domain/course-management/application/use-cases/edit-course-details.ts @@ -51,6 +51,8 @@ export class EditCourseDetailsUseCase implements UseCase { + 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 FetchCourseModulesUseCase( + inMemoryCoursesRepository, + inMemoryModulesRepository + ) + }) + + it('should be able to fetch course modules', async () => { + const instructor = makeInstructor() + await inMemoryInstructorsRepository.create(instructor) + + const course = makeCourse({ instructorId: instructor.id }) + await inMemoryCoursesRepository.create(course) + + const firstModule = makeModule({ name: 'First Module', courseId: course.id, moduleNumber: 1 }) + const secondModule = makeModule({ name: 'Second Module', courseId: course.id, moduleNumber: 2 }) + + await Promise.all([ + inMemoryModulesRepository.create(firstModule), + inMemoryModulesRepository.create(secondModule) + ]) + + const result = await sut.exec({ + courseId: course.id.toString() + }) + + expect(result.isRight()).toBe(true) + expect(result.value).toMatchObject({ + modules: expect.arrayContaining([ + expect.objectContaining({ + name: 'First Module' + }), + expect.objectContaining({ + name: 'Second Module' + }) + ]) + }) + expect(inMemoryModulesRepository.items).toHaveLength(2) + }) + + it('should not be able to fetch course modules from a inexistent course', async () => { + const result = await sut.exec({ + courseId: 'inexistentCourseId' + }) + + expect(result.isLeft()).toBe(true) + expect(result.value).toBeInstanceOf(ResourceNotFoundError) + }) +}) diff --git a/src/domain/course-management/application/use-cases/fetch-course-modules.ts b/src/domain/course-management/application/use-cases/fetch-course-modules.ts new file mode 100644 index 0000000..a5d8c63 --- /dev/null +++ b/src/domain/course-management/application/use-cases/fetch-course-modules.ts @@ -0,0 +1,40 @@ +import { left, right, type Either } from '@/core/either' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type UseCase } from '@/core/use-cases/use-case' +import { type Module } from '../../enterprise/entities/module' +import { type ModulesRepository } from '../repositories/modules-repository' +import { type CoursesRepository } from './../repositories/courses-repository' + +interface FetchCourseModulesUseCaseRequest { + courseId: string +} + +type FetchCourseModulesUseCaseResponse = Either< +ResourceNotFoundError, +{ + modules: Module[] +} +> + +export class FetchCourseModulesUseCase implements UseCase { + constructor( + private readonly coursesRepository: CoursesRepository, + private readonly modulesRepository: ModulesRepository + ) { } + + async exec({ + courseId + }: FetchCourseModulesUseCaseRequest): Promise { + const course = await this.coursesRepository.findById(courseId) + + if (!course) { + return left(new ResourceNotFoundError()) + } + + const courseModules = await this.modulesRepository.findManyByCourseId(courseId) + + return right({ + modules: courseModules + }) + } +} diff --git a/src/domain/course-management/application/use-cases/fetch-enrollment-completed-classes.spec.ts b/src/domain/course-management/application/use-cases/fetch-enrollment-completed-classes.spec.ts new file mode 100644 index 0000000..0173338 --- /dev/null +++ b/src/domain/course-management/application/use-cases/fetch-enrollment-completed-classes.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 { InMemoryStudentsRepository } from '../../../../../test/repositories/in-memory-students-repository' +import { InMemoryInstructorRepository } from './../../../../../test/repositories/in-memory-instructors-repository' +import { InMemoryModulesRepository } from './../../../../../test/repositories/in-memory-modules-repository' +import { FetchEnrollmentCompletedClassesUseCase } from './fetch-enrollment-completed-classes' + +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: FetchEnrollmentCompletedClassesUseCase + +describe('Fetch enrollment completed classes use case', () => { + beforeEach(() => { + inMemoryStudentsRepository = new InMemoryStudentsRepository() + inMemoryEnrollmentCompletedItemsRepository = new InMemoryEnrollmentCompletedItemsRepository() + inMemoryCourseTagsRepository = new InMemoryCourseTagsRepository() + inMemoryClassesRepository = new InMemoryClassesRepository() + inMemoryInstructorsRepository = new InMemoryInstructorRepository() + + inMemoryModulesRepository = new InMemoryModulesRepository(inMemoryClassesRepository) + + inMemoryEnrollmentsRepository = new InMemoryEnrollmentsRepository( + inMemoryStudentsRepository, inMemoryEnrollmentCompletedItemsRepository + ) + inMemoryCoursesRepository = new InMemoryCoursesRepository( + inMemoryModulesRepository, inMemoryInstructorsRepository, inMemoryEnrollmentsRepository, inMemoryStudentsRepository, inMemoryCourseTagsRepository + ) + + sut = new FetchEnrollmentCompletedClassesUseCase( + inMemoryEnrollmentsRepository, + inMemoryEnrollmentCompletedItemsRepository + ) + }) + + it('should be able to fetch enrollment completed classes', async () => { + const instructor = makeInstructor() + await inMemoryInstructorsRepository.create(instructor) + + const course = makeCourse({ instructorId: instructor.id }) + await inMemoryCoursesRepository.create(course) + + const module = makeModule({ + courseId: course.id, + moduleNumber: 1 + }) + await inMemoryModulesRepository.create(module) + + const firstClassToMarkAsCompleted = makeClass({ name: 'John Doe Class 1', moduleId: module.id, classNumber: 1 }) + const secondClassToMarkAsCompleted = makeClass({ name: 'John Doe Class 2', moduleId: module.id, classNumber: 2 }) + + await Promise.all([ + inMemoryClassesRepository.create(firstClassToMarkAsCompleted), + inMemoryClassesRepository.create(secondClassToMarkAsCompleted) + ]) + + const student = makeStudent() + await inMemoryStudentsRepository.create(student) + + const enrollment = makeEnrollment({ studentId: student.id, courseId: course.id }) + await inMemoryEnrollmentsRepository.create(enrollment) + + const firstCompletedItem = makeEnrollmentCompletedItem({ itemId: firstClassToMarkAsCompleted.id, enrollmentId: enrollment.id, type: 'CLASS' }) + const secondCompletedItem = makeEnrollmentCompletedItem({ itemId: secondClassToMarkAsCompleted.id, enrollmentId: enrollment.id, type: 'CLASS' }) + + await Promise.all([ + inMemoryEnrollmentCompletedItemsRepository.create(firstCompletedItem), + inMemoryEnrollmentCompletedItemsRepository.create(secondCompletedItem) + ]) + + const result = await sut.exec({ + enrollmentId: enrollment.id.toString() + }) + + expect(result.isRight()).toBe(true) + expect(result.value).toMatchObject({ + completedClasses: expect.arrayContaining([ + expect.objectContaining({ + itemId: firstCompletedItem.itemId + }), + expect.objectContaining({ + itemId: secondCompletedItem.itemId + }) + ]) + }) + expect(inMemoryEnrollmentCompletedItemsRepository.items).toHaveLength(2) + }) + + it('should not be able to fetch enrollment completed classes from a inexistent enrollment', async () => { + const result = await sut.exec({ + enrollmentId: 'inexistentEnrollmentId' + }) + + expect(result.isLeft()).toBe(true) + expect(result.value).toBeInstanceOf(ResourceNotFoundError) + }) +}) diff --git a/src/domain/course-management/application/use-cases/fetch-enrollment-completed-classes.ts b/src/domain/course-management/application/use-cases/fetch-enrollment-completed-classes.ts new file mode 100644 index 0000000..5d0c3e2 --- /dev/null +++ b/src/domain/course-management/application/use-cases/fetch-enrollment-completed-classes.ts @@ -0,0 +1,42 @@ +import { left, right, type Either } from '@/core/either' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type UseCase } from '@/core/use-cases/use-case' +import { type EnrollmentCompletedItem } from '../../enterprise/entities/enrollment-completed-item' +import { type EnrollmentCompletedItemsRepository } from '../repositories/enrollment-completed-items-repository' +import { type EnrollmentsRepository } from '../repositories/enrollments-repository' + +interface FetchEnrollmentCompletedClassesUseCaseRequest { + enrollmentId: string +} + +type FetchEnrollmentCompletedClassesUseCaseResponse = Either< +ResourceNotFoundError, +{ + completedClasses: EnrollmentCompletedItem[] +} +> + +export class FetchEnrollmentCompletedClassesUseCase implements UseCase { + constructor( + private readonly enrollmentsRepository: EnrollmentsRepository, + private readonly enrollmentCompletedItemsRepository: EnrollmentCompletedItemsRepository + ) { } + + async exec({ + enrollmentId + }: FetchEnrollmentCompletedClassesUseCaseRequest): Promise { + const enrollment = await this.enrollmentsRepository.findById(enrollmentId) + + if (!enrollment) { + return left(new ResourceNotFoundError()) + } + + const enrollmentCompletedClasses = await this.enrollmentCompletedItemsRepository.findManyCompletedClassesByEnrollmentId( + enrollmentId + ) + + return right({ + completedClasses: enrollmentCompletedClasses + }) + } +} diff --git a/src/domain/course-management/application/use-cases/fetch-enrollment-completed-modules.spec.ts b/src/domain/course-management/application/use-cases/fetch-enrollment-completed-modules.spec.ts new file mode 100644 index 0000000..e96f6f0 --- /dev/null +++ b/src/domain/course-management/application/use-cases/fetch-enrollment-completed-modules.spec.ts @@ -0,0 +1,112 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +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 { InMemoryStudentsRepository } from '../../../../../test/repositories/in-memory-students-repository' +import { InMemoryInstructorRepository } from './../../../../../test/repositories/in-memory-instructors-repository' +import { InMemoryModulesRepository } from './../../../../../test/repositories/in-memory-modules-repository' +import { FetchEnrollmentCompletedModulesUseCase } from './fetch-enrollment-completed-modules' + +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: FetchEnrollmentCompletedModulesUseCase + +describe('Fetch enrollment completed modules use case', () => { + beforeEach(() => { + inMemoryStudentsRepository = new InMemoryStudentsRepository() + inMemoryEnrollmentCompletedItemsRepository = new InMemoryEnrollmentCompletedItemsRepository() + inMemoryCourseTagsRepository = new InMemoryCourseTagsRepository() + inMemoryClassesRepository = new InMemoryClassesRepository() + inMemoryInstructorsRepository = new InMemoryInstructorRepository() + + inMemoryModulesRepository = new InMemoryModulesRepository(inMemoryClassesRepository) + + inMemoryEnrollmentsRepository = new InMemoryEnrollmentsRepository( + inMemoryStudentsRepository, inMemoryEnrollmentCompletedItemsRepository + ) + inMemoryCoursesRepository = new InMemoryCoursesRepository( + inMemoryModulesRepository, inMemoryInstructorsRepository, inMemoryEnrollmentsRepository, inMemoryStudentsRepository, inMemoryCourseTagsRepository + ) + + sut = new FetchEnrollmentCompletedModulesUseCase( + inMemoryEnrollmentsRepository, + inMemoryEnrollmentCompletedItemsRepository + ) + }) + + it('should be able to fetch enrollment completed modules', async () => { + const instructor = makeInstructor() + await inMemoryInstructorsRepository.create(instructor) + + const course = makeCourse({ instructorId: instructor.id }) + await inMemoryCoursesRepository.create(course) + + const firstModule = makeModule({ + courseId: course.id, + moduleNumber: 1 + }) + const secondModule = makeModule({ + courseId: course.id, + moduleNumber: 2 + }) + + await Promise.all([ + inMemoryModulesRepository.create(firstModule), + inMemoryModulesRepository.create(secondModule) + ]) + + const student = makeStudent() + await inMemoryStudentsRepository.create(student) + + const enrollment = makeEnrollment({ studentId: student.id, courseId: course.id }) + await inMemoryEnrollmentsRepository.create(enrollment) + + const firstCompletedItem = makeEnrollmentCompletedItem({ itemId: firstModule.id, enrollmentId: enrollment.id, type: 'MODULE' }) + const secondCompletedItem = makeEnrollmentCompletedItem({ itemId: secondModule.id, enrollmentId: enrollment.id, type: 'MODULE' }) + + await Promise.all([ + inMemoryEnrollmentCompletedItemsRepository.create(firstCompletedItem), + inMemoryEnrollmentCompletedItemsRepository.create(secondCompletedItem) + ]) + + const result = await sut.exec({ + enrollmentId: enrollment.id.toString() + }) + + expect(result.isRight()).toBe(true) + expect(result.value).toMatchObject({ + completedModules: expect.arrayContaining([ + expect.objectContaining({ + itemId: firstCompletedItem.itemId + }), + expect.objectContaining({ + itemId: secondCompletedItem.itemId + }) + ]) + }) + expect(inMemoryEnrollmentCompletedItemsRepository.items).toHaveLength(2) + }) + + it('should not be able to fetch enrollment completed modules from a inexistent enrollment', async () => { + const result = await sut.exec({ + enrollmentId: 'inexistentEnrollmentId' + }) + + expect(result.isLeft()).toBe(true) + expect(result.value).toBeInstanceOf(ResourceNotFoundError) + }) +}) diff --git a/src/domain/course-management/application/use-cases/fetch-enrollment-completed-modules.ts b/src/domain/course-management/application/use-cases/fetch-enrollment-completed-modules.ts new file mode 100644 index 0000000..b107774 --- /dev/null +++ b/src/domain/course-management/application/use-cases/fetch-enrollment-completed-modules.ts @@ -0,0 +1,42 @@ +import { left, right, type Either } from '@/core/either' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type UseCase } from '@/core/use-cases/use-case' +import { type EnrollmentCompletedItem } from '../../enterprise/entities/enrollment-completed-item' +import { type EnrollmentCompletedItemsRepository } from '../repositories/enrollment-completed-items-repository' +import { type EnrollmentsRepository } from '../repositories/enrollments-repository' + +interface FetchEnrollmentCompletedModulesUseCaseRequest { + enrollmentId: string +} + +type FetchEnrollmentCompletedModulesUseCaseResponse = Either< +ResourceNotFoundError, +{ + completedModules: EnrollmentCompletedItem[] +} +> + +export class FetchEnrollmentCompletedModulesUseCase implements UseCase { + constructor( + private readonly enrollmentsRepository: EnrollmentsRepository, + private readonly enrollmentCompletedItemsRepository: EnrollmentCompletedItemsRepository + ) { } + + async exec({ + enrollmentId + }: FetchEnrollmentCompletedModulesUseCaseRequest): Promise { + const enrollment = await this.enrollmentsRepository.findById(enrollmentId) + + if (!enrollment) { + return left(new ResourceNotFoundError()) + } + + const enrollmentCompletedModules = await this.enrollmentCompletedItemsRepository.findManyCompletedModulesByEnrollmentId( + enrollmentId + ) + + return right({ + completedModules: enrollmentCompletedModules + }) + } +} diff --git a/src/domain/course-management/application/use-cases/get-course-evaluations-average.ts b/src/domain/course-management/application/use-cases/get-course-evaluations-average.ts index f9f9af1..d88a885 100644 --- a/src/domain/course-management/application/use-cases/get-course-evaluations-average.ts +++ b/src/domain/course-management/application/use-cases/get-course-evaluations-average.ts @@ -29,7 +29,7 @@ export class GetCourseEvaluationsAverageUseCase implements UseCase< GetCourseEva sumOfCourseEvaluations += courseEvaluation.value }) - const courseEvaluationsAverage = sumOfCourseEvaluations / courseEvaluations.length + const courseEvaluationsAverage = (sumOfCourseEvaluations / courseEvaluations.length) || 0 return right({ evaluationsAverage: courseEvaluationsAverage diff --git a/src/domain/course-management/application/use-cases/get-course-instructor-details.spec.ts b/src/domain/course-management/application/use-cases/get-course-instructor-details.spec.ts new file mode 100644 index 0000000..f06c0d3 --- /dev/null +++ b/src/domain/course-management/application/use-cases/get-course-instructor-details.spec.ts @@ -0,0 +1,69 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeCourse } from '../../../../../test/factories/make-course' +import { makeInstructor } from '../../../../../test/factories/make-instructor' +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 { GetCourseInstructorDetailsUseCase } from './get-course-instructor-details' + +let inMemoryEnrollmentCompletedItemsRepository: InMemoryEnrollmentCompletedItemsRepository +let inMemoryClassesRepository: InMemoryClassesRepository +let inMemoryCourseTagsRepository: InMemoryCourseTagsRepository +let inMemoryEnrollmentsRepository: InMemoryEnrollmentsRepository +let inMemoryStudentsRepository: InMemoryStudentsRepository +let inMemoryInstructorsRepository: InMemoryInstructorRepository +let inMemoryModulesRepository: InMemoryModulesRepository +let inMemoryCoursesRepository: InMemoryCoursesRepository +let sut: GetCourseInstructorDetailsUseCase + +describe('Get course instructor details use case', () => { + beforeEach(() => { + inMemoryEnrollmentCompletedItemsRepository = new InMemoryEnrollmentCompletedItemsRepository() + inMemoryClassesRepository = new InMemoryClassesRepository() + inMemoryCourseTagsRepository = new InMemoryCourseTagsRepository() + inMemoryStudentsRepository = new InMemoryStudentsRepository() + inMemoryInstructorsRepository = new InMemoryInstructorRepository() + + inMemoryModulesRepository = new InMemoryModulesRepository(inMemoryClassesRepository) + + inMemoryEnrollmentsRepository = new InMemoryEnrollmentsRepository( + inMemoryStudentsRepository, inMemoryEnrollmentCompletedItemsRepository + ) + inMemoryCoursesRepository = new InMemoryCoursesRepository(inMemoryModulesRepository, inMemoryInstructorsRepository, inMemoryEnrollmentsRepository, inMemoryStudentsRepository, inMemoryCourseTagsRepository) + + sut = new GetCourseInstructorDetailsUseCase(inMemoryCoursesRepository, inMemoryInstructorsRepository) + }) + + it('should be able to get course instructor details', async () => { + const instructor = makeInstructor({ name: 'John Doe' }) + await inMemoryInstructorsRepository.create(instructor) + + const course = makeCourse({ name: 'John Doe Course', instructorId: instructor.id }) + await inMemoryCoursesRepository.create(course) + + const result = await sut.exec({ + courseId: course.id.toString() + }) + + expect(result.isRight()).toBe(true) + expect(result.value).toMatchObject({ + instructor: expect.objectContaining({ + name: 'John Doe' + }) + }) + }) + + it('should not be able to get course details of a inexistent course', async () => { + const result = await sut.exec({ + courseId: 'inexistentCourseId' + }) + + expect(result.isLeft()).toBe(true) + expect(result.value).toBeInstanceOf(ResourceNotFoundError) + }) +}) diff --git a/src/domain/course-management/application/use-cases/get-course-instructor-details.ts b/src/domain/course-management/application/use-cases/get-course-instructor-details.ts new file mode 100644 index 0000000..0739873 --- /dev/null +++ b/src/domain/course-management/application/use-cases/get-course-instructor-details.ts @@ -0,0 +1,44 @@ +import { left, right, type Either } from '@/core/either' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type UseCase } from '@/core/use-cases/use-case' +import { type Instructor } from '../../enterprise/entities/instructor' +import { type CoursesRepository } from '../repositories/courses-repository' +import { type InstructorsRepository } from '../repositories/instructors-repository' + +interface GetCourseInstructorDetailsUseCaseRequest { + courseId: string +} + +type GetCourseInstructorDetailsUseCaseResponse = Either< +ResourceNotFoundError, +{ + instructor: Instructor +} +> + +export class GetCourseInstructorDetailsUseCase implements UseCase { + constructor( + private readonly coursesRepository: CoursesRepository, + private readonly instructorsRepository: InstructorsRepository + ) { } + + async exec({ + courseId + }: GetCourseInstructorDetailsUseCaseRequest): Promise { + const course = await this.coursesRepository.findById(courseId) + + if (!course) { + return left(new ResourceNotFoundError()) + } + + const courseInstructor = await this.instructorsRepository.findById(course.instructorId.toString()) + + if (!courseInstructor) { + return left(new ResourceNotFoundError()) + } + + return right({ + instructor: courseInstructor + }) + } +} diff --git a/src/domain/course-management/application/use-cases/get-image-details.spec.ts b/src/domain/course-management/application/use-cases/get-image-details.spec.ts new file mode 100644 index 0000000..736da86 --- /dev/null +++ b/src/domain/course-management/application/use-cases/get-image-details.spec.ts @@ -0,0 +1,42 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeImage } from '../../../../../test/factories/make-image' +import { InMemoryImagesRepository } from './../../../../../test/repositories/in-memory-images-repository' +import { GetImageDetailsUseCase } from './get-image-details' + +let inMemoryImagesRepository: InMemoryImagesRepository +let sut: GetImageDetailsUseCase + +describe('Get image details use case', async () => { + beforeEach(() => { + inMemoryImagesRepository = new InMemoryImagesRepository() + sut = new GetImageDetailsUseCase(inMemoryImagesRepository) + }) + + it('should be able to get image details', async () => { + const name = 'john-doe-image.jpeg' + + const image = makeImage({ imageName: name }) + + await inMemoryImagesRepository.create(image) + + const result = await sut.exec({ + imageId: image.id.toString() + }) + + expect(result.isRight()).toBe(true) + expect(result.value).toMatchObject({ + image: expect.objectContaining({ + imageName: name + }) + }) + }) + + it('should not be able to get image details of a inexistent image', async () => { + const result = await sut.exec({ + imageId: 'inexistentImageId' + }) + + expect(result.isLeft()).toBe(true) + expect(result.value).toBeInstanceOf(ResourceNotFoundError) + }) +}) diff --git a/src/domain/course-management/application/use-cases/get-image-details.ts b/src/domain/course-management/application/use-cases/get-image-details.ts new file mode 100644 index 0000000..2f8d223 --- /dev/null +++ b/src/domain/course-management/application/use-cases/get-image-details.ts @@ -0,0 +1,36 @@ +import { left, right, type Either } from '@/core/either' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type UseCase } from '@/core/use-cases/use-case' +import { type Image } from '../../enterprise/entities/image' +import { type ImagesRepository } from '../repositories/images-repository' + +interface GetImageDetailsUseCaseRequest { + imageId: string +} + +type GetImageDetailsUseCaseResponse = Either< +ResourceNotFoundError, +{ + image: Image +} +> + +export class GetImageDetailsUseCase implements UseCase { + constructor( + private readonly imagesRepository: ImagesRepository + ) { } + + async exec({ + imageId + }: GetImageDetailsUseCaseRequest): Promise { + const image = await this.imagesRepository.findById(imageId) + + if (!image) { + return left(new ResourceNotFoundError()) + } + + return right({ + image + }) + } +} 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..5c06caf --- /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.itemId.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/get-video-details.spec.ts b/src/domain/course-management/application/use-cases/get-video-details.spec.ts new file mode 100644 index 0000000..4d6b853 --- /dev/null +++ b/src/domain/course-management/application/use-cases/get-video-details.spec.ts @@ -0,0 +1,42 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeVideo } from '../../../../../test/factories/make-video' +import { InMemoryVideosRepository } from './../../../../../test/repositories/in-memory-videos-repository' +import { GetVideoDetailsUseCase } from './get-video-details' + +let inMemoryVideosRepository: InMemoryVideosRepository +let sut: GetVideoDetailsUseCase + +describe('Get video details use case', async () => { + beforeEach(() => { + inMemoryVideosRepository = new InMemoryVideosRepository() + sut = new GetVideoDetailsUseCase(inMemoryVideosRepository) + }) + + it('should be able to get video details', async () => { + const name = 'john-doe-video.mp4' + + const video = makeVideo({ videoName: name }) + + await inMemoryVideosRepository.create(video) + + const result = await sut.exec({ + videoId: video.id.toString() + }) + + expect(result.isRight()).toBe(true) + expect(result.value).toMatchObject({ + video: expect.objectContaining({ + videoName: name + }) + }) + }) + + it('should not be able to get video details of a inexistent video', async () => { + const result = await sut.exec({ + videoId: 'inexistentVideoId' + }) + + expect(result.isLeft()).toBe(true) + expect(result.value).toBeInstanceOf(ResourceNotFoundError) + }) +}) diff --git a/src/domain/course-management/application/use-cases/get-video-details.ts b/src/domain/course-management/application/use-cases/get-video-details.ts new file mode 100644 index 0000000..a7f46c1 --- /dev/null +++ b/src/domain/course-management/application/use-cases/get-video-details.ts @@ -0,0 +1,36 @@ +import { left, right, type Either } from '@/core/either' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type UseCase } from '@/core/use-cases/use-case' +import { type Video } from '../../enterprise/entities/video' +import { type VideosRepository } from '../repositories/videos-repository' + +interface GetVideoDetailsUseCaseRequest { + videoId: string +} + +type GetVideoDetailsUseCaseResponse = Either< +ResourceNotFoundError, +{ + video: Video +} +> + +export class GetVideoDetailsUseCase implements UseCase { + constructor( + private readonly videosRepository: VideosRepository + ) { } + + async exec({ + videoId + }: GetVideoDetailsUseCaseRequest): Promise { + const video = await this.videosRepository.findById(videoId) + + if (!video) { + return left(new ResourceNotFoundError()) + } + + return right({ + video + }) + } +} 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 b1666b5..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,7 +57,7 @@ export class IssueCertificateUseCase implements UseCase { inMemoryEnrollmentCompletedItemsRepository.create(secondCompletedItem) ]) - await Promise.all([ - inMemoryEnrollmentsRepository.markItemAsCompleted(firstCompletedItem.id.toString(), enrollment), - inMemoryEnrollmentsRepository.markItemAsCompleted(secondCompletedItem.id.toString(), enrollment) - ]) - const result = await sut.exec({ enrollmentId: enrollment.id.toString(), studentId: student.id.toString() @@ -95,6 +91,50 @@ describe('Mark course as completed use case', () => { expect(inMemoryEnrollmentCompletedItemsRepository.items).toHaveLength(2) }) + it('should not be able to mark a enrollment of a student as completed twice', async () => { + const instructor = makeInstructor() + await inMemoryInstructorsRepository.create(instructor) + + const course = makeCourse({ instructorId: instructor.id }) + await inMemoryCoursesRepository.create(course) + + const module = makeModule({ + courseId: course.id, + moduleNumber: 1 + }) + await inMemoryModulesRepository.create(module) + + const classToMarkAsCompleted = makeClass({ name: 'John Doe Class', moduleId: module.id, classNumber: 1 }) + await inMemoryClassesRepository.create(classToMarkAsCompleted) + + const student = makeStudent() + await inMemoryStudentsRepository.create(student) + + const enrollment = makeEnrollment({ studentId: student.id, courseId: course.id }) + await inMemoryEnrollmentsRepository.create(enrollment) + + const firstCompletedItem = makeEnrollmentCompletedItem({ enrollmentId: enrollment.id, itemId: classToMarkAsCompleted.id, type: 'CLASS' }) + const secondCompletedItem = makeEnrollmentCompletedItem({ enrollmentId: enrollment.id, itemId: module.id, type: 'MODULE' }) + + await Promise.all([ + inMemoryEnrollmentCompletedItemsRepository.create(firstCompletedItem), + inMemoryEnrollmentCompletedItemsRepository.create(secondCompletedItem) + ]) + + await sut.exec({ + enrollmentId: enrollment.id.toString(), + studentId: student.id.toString() + }) + + const result = await sut.exec({ + enrollmentId: enrollment.id.toString(), + studentId: student.id.toString() + }) + + expect(result.isLeft()).toBe(true) + expect(result.value).toBeInstanceOf(ItemAlreadyCompletedError) + }) + it('should not be able to mark a inexistent enrollment of a student as completed', async () => { const instructor = makeInstructor() await inMemoryInstructorsRepository.create(instructor) @@ -154,8 +194,6 @@ describe('Mark course as completed use case', () => { const completedItem = makeEnrollmentCompletedItem({ enrollmentId: enrollment.id, itemId: classToAdd.id, type: 'CLASS' }) await inMemoryEnrollmentCompletedItemsRepository.create(completedItem) - await inMemoryEnrollmentsRepository.markItemAsCompleted(completedItem.id.toString(), enrollment) - const result = await sut.exec({ enrollmentId: enrollment.id.toString(), studentId: wrongStudent.id.toString() diff --git a/src/domain/course-management/application/use-cases/mark-course-as-completed.ts b/src/domain/course-management/application/use-cases/mark-course-as-completed.ts index a953798..0f89879 100644 --- a/src/domain/course-management/application/use-cases/mark-course-as-completed.ts +++ b/src/domain/course-management/application/use-cases/mark-course-as-completed.ts @@ -10,6 +10,7 @@ import { type EnrollmentsRepository } from '../repositories/enrollments-reposito import { type StudentsRepository } from '../repositories/students-repository' import { type ModulesRepository } from './../repositories/modules-repository' import { AllModulesInTheCourseMustBeMarkedAsCompleted } from './errors/all-modules-in-the-course-must-be-marked-as-completed' +import { ItemAlreadyCompletedError } from './errors/item-already-completed-error' interface MarkCourseAsCompletedUseCaseRequest { enrollmentId: string @@ -17,7 +18,7 @@ interface MarkCourseAsCompletedUseCaseRequest { } type MarkCourseAsCompletedUseCaseResponse = Either< -ResourceNotFoundError | NotAllowedError | AllModulesInTheCourseMustBeMarkedAsCompleted, +ResourceNotFoundError | NotAllowedError | AllModulesInTheCourseMustBeMarkedAsCompleted | ItemAlreadyCompletedError, { course: CompleteCourseDTO } @@ -51,6 +52,12 @@ export class MarkCourseAsCompletedUseCase implements UseCase { - return completedModules.includes(moduleToCompare) + return completedModules.some(completedModule => completedModule.id.toString() === moduleToCompare.id.toString()) }) if (!allModulesOfThisCourseIsCompleted) { diff --git a/src/domain/course-management/application/use-cases/register-instructor.ts b/src/domain/course-management/application/use-cases/register-instructor.ts index defe643..3dd9e8f 100644 --- a/src/domain/course-management/application/use-cases/register-instructor.ts +++ b/src/domain/course-management/application/use-cases/register-instructor.ts @@ -1,7 +1,6 @@ import { left, right, type Either } from '@/core/either' import { type UseCase } from '@/core/use-cases/use-case' -import { type Instructor } from '../../enterprise/entities/instructor' -import { Student } from '../../enterprise/entities/student' +import { Instructor } from '../../enterprise/entities/instructor' import { type HashGenerator } from '../cryptography/hash-generator' import { type InstructorsRepository } from '../repositories/instructors-repository' import { InstructorAlreadyExistsError } from './errors/instructor-already-exists-error' @@ -48,7 +47,7 @@ export class RegisterInstructorUseCase implements UseCase { +describe('Toggle mark class as completed use case', () => { beforeEach(() => { inMemoryStudentsRepository = new InMemoryStudentsRepository() inMemoryEnrollmentCompletedItemsRepository = new InMemoryEnrollmentCompletedItemsRepository() @@ -43,7 +43,7 @@ describe('Mark class as completed use case', () => { inMemoryModulesRepository, inMemoryInstructorsRepository, inMemoryEnrollmentsRepository, inMemoryStudentsRepository, inMemoryCourseTagsRepository ) - sut = new MarkClassAsCompletedUseCase( + sut = new ToggleMarkClassAsCompletedUseCase( inMemoryEnrollmentsRepository, inMemoryCoursesRepository, inMemoryModulesRepository, inMemoryClassesRepository, inMemoryStudentsRepository, inMemoryEnrollmentCompletedItemsRepository ) }) @@ -85,6 +85,44 @@ describe('Mark class as completed use case', () => { expect(inMemoryEnrollmentCompletedItemsRepository.items).toHaveLength(1) }) + it('should be able to toggle mark a class of a enrollment as completed if it already marked as completed', async () => { + const instructor = makeInstructor() + await inMemoryInstructorsRepository.create(instructor) + + const course = makeCourse({ instructorId: instructor.id }) + await inMemoryCoursesRepository.create(course) + + const module = makeModule({ + courseId: course.id, + moduleNumber: 1 + }) + await inMemoryModulesRepository.create(module) + + const classToMarkAsCompleted = makeClass({ name: 'John Doe Class', moduleId: module.id, classNumber: 1 }) + await inMemoryClassesRepository.create(classToMarkAsCompleted) + + const student = makeStudent() + await inMemoryStudentsRepository.create(student) + + const enrollment = makeEnrollment({ studentId: student.id, courseId: course.id }) + await inMemoryEnrollmentsRepository.create(enrollment) + + await sut.exec({ + enrollmentId: enrollment.id.toString(), + classId: classToMarkAsCompleted.id.toString(), + studentId: student.id.toString() + }) + + const result = await sut.exec({ + enrollmentId: enrollment.id.toString(), + classId: classToMarkAsCompleted.id.toString(), + studentId: student.id.toString() + }) + + expect(result.isRight()).toBe(true) + expect(inMemoryEnrollmentCompletedItemsRepository.items).toHaveLength(0) + }) + it('should not be able to mark a inexistent class as completed', async () => { const instructor = makeInstructor() await inMemoryInstructorsRepository.create(instructor) diff --git a/src/domain/course-management/application/use-cases/mark-class-as-completed.ts b/src/domain/course-management/application/use-cases/toggle-mark-class-as-completed.ts similarity index 80% rename from src/domain/course-management/application/use-cases/mark-class-as-completed.ts rename to src/domain/course-management/application/use-cases/toggle-mark-class-as-completed.ts index 5a3f202..0d8c86d 100644 --- a/src/domain/course-management/application/use-cases/mark-class-as-completed.ts +++ b/src/domain/course-management/application/use-cases/toggle-mark-class-as-completed.ts @@ -11,20 +11,20 @@ import { type StudentsRepository } from '../repositories/students-repository' import { type ClassesRepository } from './../repositories/classes-repository' import { type ModulesRepository } from './../repositories/modules-repository' -interface MarkClassAsCompletedUseCaseRequest { +interface ToggleMarkClassAsCompletedUseCaseRequest { enrollmentId: string studentId: string classId: string } -type MarkClassAsCompletedUseCaseResponse = Either< +type ToggleMarkClassAsCompletedUseCaseResponse = Either< ResourceNotFoundError | NotAllowedError, { class: Class } > -export class MarkClassAsCompletedUseCase implements UseCase { +export class ToggleMarkClassAsCompletedUseCase implements UseCase { constructor( private readonly enrollmentsRepository: EnrollmentsRepository, private readonly coursesRepository: CoursesRepository, @@ -38,7 +38,7 @@ export class MarkClassAsCompletedUseCase implements UseCase { + }: ToggleMarkClassAsCompletedUseCaseRequest): Promise { const [enrollment, student, classToMarkAsCompleted] = await Promise.all([ this.enrollmentsRepository.findById(enrollmentId), this.studentsRepository.findById(studentId), @@ -55,6 +55,19 @@ export class MarkClassAsCompletedUseCase implements UseCase { beforeEach(() => { @@ -45,7 +45,7 @@ describe('Mark module as completed use case', () => { inMemoryModulesRepository, inMemoryInstructorsRepository, inMemoryEnrollmentsRepository, inMemoryStudentsRepository, inMemoryCourseTagsRepository ) - sut = new MarkModuleAsCompletedUseCase( + sut = new ToggleMarkModuleAsCompletedUseCase( inMemoryEnrollmentsRepository, inMemoryModulesRepository, inMemoryClassesRepository, inMemoryStudentsRepository, inMemoryEnrollmentCompletedItemsRepository ) }) @@ -76,8 +76,6 @@ describe('Mark module as completed use case', () => { const completedItem = makeEnrollmentCompletedItem({ enrollmentId: enrollment.id, itemId: classToAdd.id, type: 'CLASS' }) await inMemoryEnrollmentCompletedItemsRepository.create(completedItem) - await inMemoryEnrollmentsRepository.markItemAsCompleted(completedItem.id.toString(), enrollment) - const result = await sut.exec({ enrollmentId: enrollment.id.toString(), moduleId: module.id.toString(), @@ -93,6 +91,48 @@ describe('Mark module as completed use case', () => { expect(inMemoryEnrollmentCompletedItemsRepository.items).toHaveLength(2) }) + it('should be able to toggle mark a module of a enrollment as completed if it already marked as completed', async () => { + const instructor = makeInstructor() + await inMemoryInstructorsRepository.create(instructor) + + const course = makeCourse({ 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) + + await sut.exec({ + enrollmentId: enrollment.id.toString(), + moduleId: module.id.toString(), + studentId: student.id.toString() + }) + + const result = await sut.exec({ + enrollmentId: enrollment.id.toString(), + moduleId: module.id.toString(), + studentId: student.id.toString() + }) + + expect(result.isRight()).toBe(true) + expect(inMemoryEnrollmentCompletedItemsRepository.items).toHaveLength(1) + }) + it('should not be able to mark a inexistent module of a enrollment as completed', async () => { const instructor = makeInstructor() await inMemoryInstructorsRepository.create(instructor) @@ -147,8 +187,6 @@ describe('Mark module as completed use case', () => { const completedItem = makeEnrollmentCompletedItem({ enrollmentId: enrollment.id, itemId: classToAdd.id, type: 'CLASS' }) await inMemoryEnrollmentCompletedItemsRepository.create(completedItem) - await inMemoryEnrollmentsRepository.markItemAsCompleted(completedItem.id.toString(), enrollment) - const result = await sut.exec({ enrollmentId: enrollment.id.toString(), moduleId: module.id.toString(), diff --git a/src/domain/course-management/application/use-cases/mark-module-as-completed.ts b/src/domain/course-management/application/use-cases/toggle-mark-module-as-completed.ts similarity index 81% rename from src/domain/course-management/application/use-cases/mark-module-as-completed.ts rename to src/domain/course-management/application/use-cases/toggle-mark-module-as-completed.ts index ad09747..4d9fbc9 100644 --- a/src/domain/course-management/application/use-cases/mark-module-as-completed.ts +++ b/src/domain/course-management/application/use-cases/toggle-mark-module-as-completed.ts @@ -12,20 +12,20 @@ import { type StudentsRepository } from '../repositories/students-repository' import { type ModulesRepository } from './../repositories/modules-repository' import { AllClassesInTheModuleMustBeMarkedAsCompleted } from './errors/all-classes-in-the-module-must-be-marked-as-completed' -interface MarkModuleAsCompletedUseCaseRequest { +interface ToggleMarkModuleAsCompletedUseCaseRequest { enrollmentId: string studentId: string moduleId: string } -type MarkModuleAsCompletedUseCaseResponse = Either< +type ToggleMarkModuleAsCompletedUseCaseResponse = Either< ResourceNotFoundError | NotAllowedError | AllClassesInTheModuleMustBeMarkedAsCompleted, { module: Module } > -export class MarkModuleAsCompletedUseCase implements UseCase { +export class ToggleMarkModuleAsCompletedUseCase implements UseCase { constructor( private readonly enrollmentsRepository: EnrollmentsRepository, private readonly modulesRepository: ModulesRepository, @@ -38,7 +38,7 @@ export class MarkModuleAsCompletedUseCase implements UseCase { + }: ToggleMarkModuleAsCompletedUseCaseRequest): Promise { const [enrollment, student, module] = await Promise.all([ this.enrollmentsRepository.findById(enrollmentId), this.studentsRepository.findById(studentId), @@ -55,6 +55,19 @@ export class MarkModuleAsCompletedUseCase implements UseCase moduleToMap.id.toString()) @@ -82,7 +95,7 @@ export class MarkModuleAsCompletedUseCase implements UseCase { - return completedClasses.includes(classToCompare) + return completedClasses.some(completedClass => completedClass.id.toString() === classToCompare.id.toString()) }) if (!allClassesOfThisModuleIsCompleted) { @@ -96,8 +109,6 @@ export class MarkModuleAsCompletedUseCase implements UseCase { + if (!/image\/(jpeg|png)/.test(imageType)) { + return left(new InvalidMimeTypeError(imageType)) + } + const image = Image.create({ imageName, imageType, diff --git a/src/domain/course-management/application/use-cases/upload-video.ts b/src/domain/course-management/application/use-cases/upload-video.ts index 5b211ca..a9e9a62 100644 --- a/src/domain/course-management/application/use-cases/upload-video.ts +++ b/src/domain/course-management/application/use-cases/upload-video.ts @@ -1,18 +1,19 @@ -import { right, type Either } from '@/core/either' +import { left, right, type Either } from '@/core/either' +import { InvalidMimeTypeError } from '@/core/errors/errors/invalid-mime-type-error' import { type UseCase } from '@/core/use-cases/use-case' import { Video } from '../../enterprise/entities/video' import { type VideosRepository } from './../repositories/videos-repository' interface UploadVideoUseCaseRequest { videoName: string - videoType?: 'video/mp4' | 'video/avi' + videoType: 'video/mp4' | 'video/avi' body: Buffer duration: number size: number } type UploadVideoUseCaseResponse = Either< -null, +InvalidMimeTypeError, { video: Video } @@ -30,6 +31,10 @@ export class UploadVideoUseCase implements UseCase { + if (!/video\/(mp4|avi)/.test(videoType)) { + return left(new InvalidMimeTypeError(videoType)) + } + const video = Video.create({ videoName, videoType, diff --git a/src/domain/course-management/enterprise/entities/dtos/class-with-student-progress.ts b/src/domain/course-management/enterprise/entities/dtos/class-with-student-progress.ts new file mode 100644 index 0000000..ed817df --- /dev/null +++ b/src/domain/course-management/enterprise/entities/dtos/class-with-student-progress.ts @@ -0,0 +1,6 @@ +import { type ClassDTO } from './class' + +export interface ClassWithStudentProgressDTO { + class: ClassDTO + completed: boolean +} diff --git a/src/domain/course-management/enterprise/entities/dtos/class.ts b/src/domain/course-management/enterprise/entities/dtos/class.ts new file mode 100644 index 0000000..f1ab7cc --- /dev/null +++ b/src/domain/course-management/enterprise/entities/dtos/class.ts @@ -0,0 +1,10 @@ +import { type UniqueEntityID } from '@/core/entities/unique-entity-id' + +export interface ClassDTO { + id: UniqueEntityID + name: string + description: string + videoId: UniqueEntityID + classNumber: number + moduleId: UniqueEntityID +} diff --git a/src/domain/course-management/enterprise/entities/dtos/complete-course-with-student-progress.ts b/src/domain/course-management/enterprise/entities/dtos/complete-course-with-student-progress.ts deleted file mode 100644 index 16387fc..0000000 --- a/src/domain/course-management/enterprise/entities/dtos/complete-course-with-student-progress.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type CourseDTO } from './course' -import { type InstructorDTO } from './instructor' -import { type ModuleWithClassesAndStudentProgressDTO } from './module-with-classes-and-student-progress' - -export interface CompleteCourseWithStudentProgressDTO { - course: CourseDTO - instructor: InstructorDTO - modules: ModuleWithClassesAndStudentProgressDTO[] -} diff --git a/src/domain/course-management/enterprise/entities/dtos/course-with-instructor-and-evaluation.ts b/src/domain/course-management/enterprise/entities/dtos/course-with-instructor-and-evaluation.ts index 61c29e0..b7d75e7 100644 --- a/src/domain/course-management/enterprise/entities/dtos/course-with-instructor-and-evaluation.ts +++ b/src/domain/course-management/enterprise/entities/dtos/course-with-instructor-and-evaluation.ts @@ -1,8 +1,8 @@ import { type CourseDTO } from './course' import { type InstructorDTO } from './instructor' -export interface CourseWithInstructorAndEvaluation { +export interface CourseWithInstructorAndEvaluationDTO { course: CourseDTO instructor: InstructorDTO - evaluationAverage: number + evaluationsAverage: number } diff --git a/src/domain/course-management/enterprise/entities/dtos/mappers/class-dto-mapper.ts b/src/domain/course-management/enterprise/entities/dtos/mappers/class-dto-mapper.ts new file mode 100644 index 0000000..02e872e --- /dev/null +++ b/src/domain/course-management/enterprise/entities/dtos/mappers/class-dto-mapper.ts @@ -0,0 +1,15 @@ +import { type Class } from '../../class' +import { type ClassDTO } from '../class' + +export class ClassDtoMapper { + static toDTO(classToMap: Class): ClassDTO { + return { + id: classToMap.id, + name: classToMap.name, + description: classToMap.description, + classNumber: classToMap.classNumber, + moduleId: classToMap.moduleId, + videoId: classToMap.videoId + } + } +} diff --git a/src/domain/course-management/enterprise/entities/dtos/mappers/course-dto-mapper.ts b/src/domain/course-management/enterprise/entities/dtos/mappers/course-dto-mapper.ts new file mode 100644 index 0000000..e67413f --- /dev/null +++ b/src/domain/course-management/enterprise/entities/dtos/mappers/course-dto-mapper.ts @@ -0,0 +1,17 @@ +import { type Course } from '../../course' +import { type CourseDTO } from '../course' + +export class CourseDtoMapper { + static toDTO(course: Course): CourseDTO { + return { + id: course.id, + name: course.name, + description: course.description, + createdAt: course.createdAt, + bannerImageKey: course.bannerImageKey, + coverImageKey: course.coverImageKey + } + } + + // May have toEntity method to return to the original entity format +} diff --git a/src/domain/course-management/enterprise/entities/dtos/mappers/module-dto-mapper.ts b/src/domain/course-management/enterprise/entities/dtos/mappers/module-dto-mapper.ts new file mode 100644 index 0000000..a1d4098 --- /dev/null +++ b/src/domain/course-management/enterprise/entities/dtos/mappers/module-dto-mapper.ts @@ -0,0 +1,14 @@ +import { type Module } from '../../module' +import { type ModuleDTO } from '../module' + +export class ModuleDtoMapper { + static toDTO(module: Module): ModuleDTO { + return { + id: module.id, + name: module.name, + description: module.description, + moduleNumber: module.moduleNumber, + courseId: module.courseId + } + } +} diff --git a/src/domain/course-management/enterprise/entities/dtos/module-with-classes-and-student-progress.ts b/src/domain/course-management/enterprise/entities/dtos/module-with-classes-and-student-progress.ts deleted file mode 100644 index b1e5b76..0000000 --- a/src/domain/course-management/enterprise/entities/dtos/module-with-classes-and-student-progress.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { type Class } from '../class' -import { type ModuleDTO } from './module' - -interface ModuleWithProgress extends ModuleDTO { - completed: boolean -} - -interface ClassWithProgress extends Class { - completed: boolean -} - -export interface ModuleWithClassesAndStudentProgressDTO { - module: ModuleWithProgress - classes: ClassWithProgress[] -} diff --git a/src/domain/course-management/enterprise/entities/dtos/module-with-student-progress.ts b/src/domain/course-management/enterprise/entities/dtos/module-with-student-progress.ts new file mode 100644 index 0000000..c053c73 --- /dev/null +++ b/src/domain/course-management/enterprise/entities/dtos/module-with-student-progress.ts @@ -0,0 +1,6 @@ +import { type ModuleDTO } from './module' + +export interface ModuleWithStudentProgressDTO { + module: ModuleDTO + completed: boolean +} diff --git a/src/domain/course-management/enterprise/entities/enrollment.ts b/src/domain/course-management/enterprise/entities/enrollment.ts index 2269833..0524cd5 100644 --- a/src/domain/course-management/enterprise/entities/enrollment.ts +++ b/src/domain/course-management/enterprise/entities/enrollment.ts @@ -5,7 +5,6 @@ import { type Optional } from '@/core/types/optional' export interface EnrollmentProps { studentId: UniqueEntityID courseId: UniqueEntityID - completedItems: UniqueEntityID[] // References for enrollment-completed-item entity ocurredAt: Date completedAt?: Date | null } @@ -19,10 +18,6 @@ export class Enrollment extends Entity { return this.props.courseId } - get completedItems() { - return this.props.completedItems - } - get ocurredAt() { return this.props.ocurredAt } @@ -36,15 +31,13 @@ export class Enrollment extends Entity { } static create( - props: Optional, + props: Optional, id?: UniqueEntityID ) { const enrollment = new Enrollment( { ...props, - ocurredAt: props.ocurredAt ?? new Date(), - completedAt: null, - completedItems: [] + ocurredAt: props.ocurredAt ?? new Date() }, id ) diff --git a/src/domain/course-management/enterprise/entities/image.ts b/src/domain/course-management/enterprise/entities/image.ts index 913211e..f84f874 100644 --- a/src/domain/course-management/enterprise/entities/image.ts +++ b/src/domain/course-management/enterprise/entities/image.ts @@ -1,18 +1,17 @@ -import { AggregateRoot } from '@/core/entities/aggregate-root' +import { Entity } from '@/core/entities/entity' import { type UniqueEntityID } from '@/core/entities/unique-entity-id' import { type Optional } from '@/core/types/optional' -import { ImageUploadedEvent } from '../events/image-uploaded' export interface ImageProps { imageName: string imageType: 'image/jpeg' | 'image/png' body: Buffer size: number - imageKey?: string + imageKey?: string | null storedAt: Date } -export class Image extends AggregateRoot { +export class Image extends Entity { get imageName() { return this.props.imageName } @@ -54,12 +53,6 @@ export class Image extends AggregateRoot { id ) - const isNewImage = !id - - if (isNewImage) { - image.addDomainEvent(new ImageUploadedEvent(image)) - } - return image } } diff --git a/src/domain/course-management/enterprise/entities/video.ts b/src/domain/course-management/enterprise/entities/video.ts index ecff342..09e5cac 100644 --- a/src/domain/course-management/enterprise/entities/video.ts +++ b/src/domain/course-management/enterprise/entities/video.ts @@ -1,7 +1,6 @@ -import { AggregateRoot } from '@/core/entities/aggregate-root' +import { Entity } from '@/core/entities/entity' import { type UniqueEntityID } from '@/core/entities/unique-entity-id' import { type Optional } from '@/core/types/optional' -import { VideoUploadedEvent } from '../events/video-uploaded' export interface VideoProps { videoName: string @@ -9,11 +8,11 @@ export interface VideoProps { body: Buffer duration: number size: number - videoKey?: string + videoKey?: string | null storedAt: Date } -export class Video extends AggregateRoot { +export class Video extends Entity { get videoName() { return this.props.videoName } @@ -59,12 +58,6 @@ export class Video extends AggregateRoot { id ) - const isNewVideo = !id - - if (isNewVideo) { - video.addDomainEvent(new VideoUploadedEvent(video)) - } - return video } } diff --git a/src/domain/course-management/enterprise/events/video-uploaded.ts b/src/domain/course-management/enterprise/events/video-uploaded.ts deleted file mode 100644 index 37103dd..0000000 --- a/src/domain/course-management/enterprise/events/video-uploaded.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { type UniqueEntityID } from '@/core/entities/unique-entity-id' -import { type DomainEvent } from '@/core/events/domain-event' -import { type Video } from '../entities/video' - -export class VideoUploadedEvent implements DomainEvent { - public video: Video - public ocurredAt: Date - - constructor(video: Video) { - this.video = video - this.ocurredAt = new Date() - } - - getAggregateId(): UniqueEntityID { - return this.video.id - } -} diff --git a/src/domain/storage/application/subscribers/on-image-uploaded.spec.ts b/src/domain/storage/application/subscribers/on-image-uploaded.spec.ts deleted file mode 100644 index 20dbe82..0000000 --- a/src/domain/storage/application/subscribers/on-image-uploaded.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { makeImage } from '../../../../../test/factories/make-image' -import { InMemoryFilesRepository } from '../../../../../test/repositories/in-memory-files-repository' -import { InMemoryImagesRepository } from '../../../../../test/repositories/in-memory-images-repository' -import { FakeUploader } from '../../../../../test/storage/fake-uploader' -import { waitFor } from '../../../../../test/utils/wait-for' -import { UploadFileUseCase } from './../use-cases/upload-file' -import { OnImageUploaded } from './on-image-uploaded' - -let inMemoryFilesRepository: InMemoryFilesRepository -let inMemoryImagesRepository: InMemoryImagesRepository -let fakeUploader: FakeUploader -let uploadFileUseCase: UploadFileUseCase - -describe('On image uploaded', () => { - beforeEach(() => { - inMemoryFilesRepository = new InMemoryFilesRepository() - inMemoryImagesRepository = new InMemoryImagesRepository() - fakeUploader = new FakeUploader() - uploadFileUseCase = new UploadFileUseCase(inMemoryFilesRepository, fakeUploader) - - new OnImageUploaded(uploadFileUseCase, inMemoryImagesRepository) - }) - - it('should be able to upload a image', async () => { - const image = makeImage() - await inMemoryImagesRepository.create(image) - - expect(inMemoryFilesRepository.items[0]).toMatchObject({ - fileName: image.imageName - }) - expect(fakeUploader.files[0]).toMatchObject({ - fileName: image.imageName - }) - }) - - it('should append generated fileKey to pattern file on courses domain', async () => { - const image = makeImage() - await inMemoryImagesRepository.create(image) - - await waitFor(() => { - expect(inMemoryImagesRepository.items[0]).toMatchObject({ - imageKey: fakeUploader.files[0].fileKey - }) - }) - }) -}) diff --git a/src/domain/storage/application/subscribers/on-image-uploaded.ts b/src/domain/storage/application/subscribers/on-image-uploaded.ts deleted file mode 100644 index 0ce9b1f..0000000 --- a/src/domain/storage/application/subscribers/on-image-uploaded.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { DomainEvents } from '@/core/events/domain-events' -import { type EventHandler } from '@/core/events/event-handler' -import { type ImagesRepository } from '@/domain/course-management/application/repositories/images-repository' -import { ImageUploadedEvent } from '@/domain/course-management/enterprise/events/image-uploaded' -import { type UploadFileUseCase } from '../use-cases/upload-file' - -export class OnImageUploaded implements EventHandler { - constructor( - private readonly uploadFileUseCase: UploadFileUseCase, - private readonly imagesRepository: ImagesRepository - ) { - this.setupSubscriptions() - } - - setupSubscriptions(): void { - DomainEvents.register( - this.uploadImage.bind(this) as (event: unknown) => void, - ImageUploadedEvent.name - ) - } - - private async uploadImage({ image }: ImageUploadedEvent) { - const result = await this.uploadFileUseCase.exec({ - fileName: image.imageName, - fileType: image.imageType, - body: image.body, - size: image.size, - storedAt: image.storedAt - }) - - if (result.isRight()) { - const { fileKey } = result.value.file - - await this.imagesRepository.appendImageKey(fileKey, image.id.toString()) - } - } -} diff --git a/src/domain/storage/application/subscribers/on-video-uploaded.spec.ts b/src/domain/storage/application/subscribers/on-video-uploaded.spec.ts deleted file mode 100644 index b84a45c..0000000 --- a/src/domain/storage/application/subscribers/on-video-uploaded.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { makeVideo } from '../../../../../test/factories/make-video' -import { InMemoryFilesRepository } from '../../../../../test/repositories/in-memory-files-repository' -import { InMemoryVideosRepository } from '../../../../../test/repositories/in-memory-videos-repository' -import { FakeUploader } from '../../../../../test/storage/fake-uploader' -import { waitFor } from '../../../../../test/utils/wait-for' -import { UploadFileUseCase } from './../use-cases/upload-file' -import { OnVideoUploaded } from './on-video-uploaded' - -let inMemoryFilesRepository: InMemoryFilesRepository -let inMemoryVideosRepository: InMemoryVideosRepository -let fakeUploader: FakeUploader -let uploadFileUseCase: UploadFileUseCase - -describe('On video uploaded', () => { - beforeEach(() => { - inMemoryFilesRepository = new InMemoryFilesRepository() - inMemoryVideosRepository = new InMemoryVideosRepository() - fakeUploader = new FakeUploader() - uploadFileUseCase = new UploadFileUseCase(inMemoryFilesRepository, fakeUploader) - - new OnVideoUploaded(uploadFileUseCase, inMemoryVideosRepository) - }) - - it('should be able to upload a video', async () => { - const video = makeVideo() - await inMemoryVideosRepository.create(video) - - expect(inMemoryFilesRepository.items[0]).toMatchObject({ - fileName: video.videoName - }) - expect(fakeUploader.files[0]).toMatchObject({ - fileName: video.videoName - }) - }) - - it('should append generated fileKey to pattern file on courses domain', async () => { - const video = makeVideo() - await inMemoryVideosRepository.create(video) - - await waitFor(() => { - expect(inMemoryVideosRepository.items[0]).toMatchObject({ - videoKey: fakeUploader.files[0].fileKey - }) - }) - }) -}) diff --git a/src/domain/storage/application/subscribers/on-video-uploaded.ts b/src/domain/storage/application/subscribers/on-video-uploaded.ts deleted file mode 100644 index 84afd12..0000000 --- a/src/domain/storage/application/subscribers/on-video-uploaded.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { DomainEvents } from '@/core/events/domain-events' -import { type EventHandler } from '@/core/events/event-handler' -import { type VideosRepository } from '@/domain/course-management/application/repositories/videos-repository' -import { VideoUploadedEvent } from '@/domain/course-management/enterprise/events/video-uploaded' -import { type UploadFileUseCase } from '../use-cases/upload-file' - -export class OnVideoUploaded implements EventHandler { - constructor( - private readonly uploadFileUseCase: UploadFileUseCase, - private readonly videosRepository: VideosRepository - ) { - this.setupSubscriptions() - } - - setupSubscriptions(): void { - DomainEvents.register( - this.uploadVideo.bind(this) as (event: unknown) => void, - VideoUploadedEvent.name - ) - } - - private async uploadVideo({ video }: VideoUploadedEvent) { - const result = await this.uploadFileUseCase.exec({ - fileName: video.videoName, - fileType: video.videoType, - body: video.body, - size: video.size, - storedAt: video.storedAt - }) - - if (result.isRight()) { - const { fileKey } = result.value.file - - await this.videosRepository.appendVideoKey(fileKey, video.id.toString()) - } - } -} diff --git a/src/domain/storage/application/upload/uploader.ts b/src/domain/storage/application/upload/uploader.ts index ad41569..3f51de2 100644 --- a/src/domain/storage/application/upload/uploader.ts +++ b/src/domain/storage/application/upload/uploader.ts @@ -3,7 +3,6 @@ export interface UploadParams { fileType: string body: Buffer size: number - storedAt: Date } export interface Uploader { diff --git a/src/domain/storage/application/use-cases/upload-file.spec.ts b/src/domain/storage/application/use-cases/upload-file.spec.ts index bc95d16..91732a6 100644 --- a/src/domain/storage/application/use-cases/upload-file.spec.ts +++ b/src/domain/storage/application/use-cases/upload-file.spec.ts @@ -1,6 +1,6 @@ +import { InvalidMimeTypeError } from '@/core/errors/errors/invalid-mime-type-error' import { InMemoryFilesRepository } from '../../../../../test/repositories/in-memory-files-repository' import { FakeUploader } from '../../../../../test/storage/fake-uploader' -import { InvalidMimeTypeError } from './errors/invalid-mime-type-error' import { UploadFileUseCase } from './upload-file' let inMemoryFilesRepository: InMemoryFilesRepository @@ -19,8 +19,7 @@ describe('Upload file use case', () => { fileName: 'profile', fileType: 'image/jpeg', size: 1024 * 1024, // One megabyte - body: Buffer.from('body-image'), - storedAt: new Date() + body: Buffer.from('body-image') }) expect(result.isRight()).toBe(true) @@ -37,8 +36,7 @@ describe('Upload file use case', () => { fileName: 'profile', fileType: 'image/svg', // Invalid Mime Type size: 1024 * 1024, // One megabyte - body: Buffer.from('body-image'), - storedAt: new Date() + body: Buffer.from('body-image') }) expect(result.isLeft()).toBe(true) diff --git a/src/domain/storage/application/use-cases/upload-file.ts b/src/domain/storage/application/use-cases/upload-file.ts index e6fbf6f..9339f44 100644 --- a/src/domain/storage/application/use-cases/upload-file.ts +++ b/src/domain/storage/application/use-cases/upload-file.ts @@ -1,16 +1,15 @@ import { left, right, type Either } from '@/core/either' +import { InvalidMimeTypeError } from '@/core/errors/errors/invalid-mime-type-error' import { type UseCase } from '@/core/use-cases/use-case' import { File } from '../../enterprise/entities/file' import { type FilesRepository } from '../repositories/files-repository' import { type Uploader } from '../upload/uploader' -import { InvalidMimeTypeError } from './errors/invalid-mime-type-error' interface UploadFileUseCaseRequest { fileName: string fileType: string body: Buffer size: number - storedAt: Date } type UploadFileUseCaseResponse = Either< @@ -30,22 +29,20 @@ export class UploadFileUseCase implements UseCase { if (!/image\/(jpeg|png)|video\/(mp4|avi)/.test(fileType)) { return left(new InvalidMimeTypeError(fileType)) } - const { key } = await this.uploader.upload({ fileName, fileType, body, size, storedAt }) + const { key } = await this.uploader.upload({ fileName, fileType, body, size }) const file = File.create({ fileName, fileType, body, fileKey: key, - size, - storedAt + size }) await this.filesRepository.create(file) diff --git a/src/domain/storage/enterprise/entities/file.ts b/src/domain/storage/enterprise/entities/file.ts index dc5dd8b..9fda84b 100644 --- a/src/domain/storage/enterprise/entities/file.ts +++ b/src/domain/storage/enterprise/entities/file.ts @@ -1,5 +1,7 @@ import { AggregateRoot } from '@/core/entities/aggregate-root' import { type UniqueEntityID } from '@/core/entities/unique-entity-id' +import { type Optional } from '@/core/types/optional' +import { FileUploadedEvent } from '../events/file-uploaded' export interface FileProps { fileName: string @@ -36,16 +38,23 @@ export class File extends AggregateRoot { } static create( - props: FileProps, + props: Optional, id?: UniqueEntityID ) { const file = new File( { + storedAt: new Date(), ...props }, id ) + const isNewFile = !id + + if (isNewFile) { + file.addDomainEvent(new FileUploadedEvent(file)) + } + return file } } diff --git a/src/domain/course-management/enterprise/events/image-uploaded.ts b/src/domain/storage/enterprise/events/file-uploaded.ts similarity index 54% rename from src/domain/course-management/enterprise/events/image-uploaded.ts rename to src/domain/storage/enterprise/events/file-uploaded.ts index 2379d75..42e01cb 100644 --- a/src/domain/course-management/enterprise/events/image-uploaded.ts +++ b/src/domain/storage/enterprise/events/file-uploaded.ts @@ -1,17 +1,17 @@ import { type UniqueEntityID } from '@/core/entities/unique-entity-id' import { type DomainEvent } from '@/core/events/domain-event' -import { type Image } from '../entities/image' +import { type File } from '../entities/file' -export class ImageUploadedEvent implements DomainEvent { - public image: Image +export class FileUploadedEvent implements DomainEvent { + public file: File public ocurredAt: Date - constructor(image: Image) { - this.image = image + constructor(file: File) { + this.file = file this.ocurredAt = new Date() } getAggregateId(): UniqueEntityID { - return this.image.id + return this.file.id } } diff --git a/src/infra/@types/fastify-jwt.d.ts b/src/infra/@types/fastify-jwt.d.ts new file mode 100644 index 0000000..c0a1c0c --- /dev/null +++ b/src/infra/@types/fastify-jwt.d.ts @@ -0,0 +1,10 @@ +import '@fastify/jwt' + +declare module '@fastify/jwt' { + export interface FastifyJWT { + user: { + sub: string + role: 'STUDENT' | 'INSTRUCTOR' + } + } +} diff --git a/src/infra/app.ts b/src/infra/app.ts index 3e20f8e..fdbb4ce 100644 --- a/src/infra/app.ts +++ b/src/infra/app.ts @@ -1,9 +1,76 @@ +import fastifyCookie from '@fastify/cookie' +import fastifyCors from '@fastify/cors' +import fastifyJwt from '@fastify/jwt' import fastify from 'fastify' +import multer from 'fastify-multer' +import { readFileSync } from 'fs' import { ZodError } from 'zod' import { env } from './env' +import { certificateRoutes } from './http/routes/certificate' +import { classRoutes } from './http/routes/class' +import { courseRoutes } from './http/routes/course' +import { courseTagRoutes } from './http/routes/course-tag' +import { enrollmentRoutes } from './http/routes/enrollment' +import { evaluationRoutes } from './http/routes/evaluation' +import { fileRoutes } from './http/routes/file' +import { imageRoutes } from './http/routes/image' +import { instructorRoutes } from './http/routes/instructor' +import { moduleRoutes } from './http/routes/module' +import { studentRoutes } from './http/routes/student' +import { studentCertificateRoutes } from './http/routes/student-certificate' +import { tagRoutes } from './http/routes/tag' +import { userRoutes } from './http/routes/user' +import { videoRoutes } from './http/routes/video' export const app = fastify() +// PLugins + +app.register(fastifyCors, { + credentials: true, + exposedHeaders: ['set-cookie'] +}) + +app.register(fastifyJwt, { + secret: { + private: readFileSync('./private-key.pem'), + public: readFileSync('./public-key.pem') + }, + cookie: { + cookieName: 'spark.accesstoken', + signed: false + }, + sign: { + algorithm: 'RS256', + expiresIn: '1d' + } +}) + +app.register(fastifyCookie) + +export const upload = multer() +app.register(multer.contentParser) + +// API Routes + +app.register(userRoutes) +app.register(studentRoutes) +app.register(instructorRoutes) +app.register(courseRoutes) +app.register(fileRoutes) +app.register(imageRoutes) +app.register(videoRoutes) +app.register(moduleRoutes) +app.register(classRoutes) +app.register(tagRoutes) +app.register(courseTagRoutes) +app.register(evaluationRoutes) +app.register(enrollmentRoutes) +app.register(certificateRoutes) +app.register(studentCertificateRoutes) + +// Custom error handler + app.setErrorHandler((error, _, reply) => { if (error instanceof ZodError) { return reply diff --git a/src/infra/cryptography/jwt-encrypter.ts b/src/infra/cryptography/jwt-encrypter.ts index ed18070..b7dd40f 100644 --- a/src/infra/cryptography/jwt-encrypter.ts +++ b/src/infra/cryptography/jwt-encrypter.ts @@ -1,7 +1,10 @@ -import { type Encrypter } from '@/domain/course-management/application/cryptography/encrypter' +import { type Encrypter, type EncrypterProps } from '@/domain/course-management/application/cryptography/encrypter' +import { type FastifyReply } from 'fastify' -export class FakeEncrypter implements Encrypter { - async encrypt(payload: Record): Promise { - return JSON.stringify(payload) +export class JWTEncrypter implements Encrypter { + constructor(private readonly reply: FastifyReply) {} + + async encrypt(payload: EncrypterProps): Promise { + return await this.reply.jwtSign({ role: payload.role }, { sub: payload.sub }) } } diff --git a/src/infra/database/prisma/mappers/enrollment-mapeer.ts b/src/infra/database/prisma/mappers/enrollment-mapper.ts similarity index 65% rename from src/infra/database/prisma/mappers/enrollment-mapeer.ts rename to src/infra/database/prisma/mappers/enrollment-mapper.ts index 755e10e..0741131 100644 --- a/src/infra/database/prisma/mappers/enrollment-mapeer.ts +++ b/src/infra/database/prisma/mappers/enrollment-mapper.ts @@ -8,24 +8,20 @@ export class EnrollmentMapper { private readonly enrollmentCompletedItemsRepository: EnrollmentCompletedItemsRepository ) {} - async toDomain(raw: PrismaEnrollment): Promise { - const completedItems = await this.enrollmentCompletedItemsRepository.findAllByEnrollmentId(raw.id) - const completedItemIds = completedItems.map(completedItem => completedItem.id) - + async toDomain(raw: PrismaEnrollment): Promise { return Enrollment.create( { courseId: new UniqueEntityID(raw.courseId), studentId: new UniqueEntityID(raw.studentId), completedAt: raw.completedAt, - ocurredAt: raw.ocurredAt, - completedItems: completedItemIds + ocurredAt: raw.ocurredAt }, new UniqueEntityID(raw.id) ) } - async toPrisma(enrollment: Enrollment): Promise { - const completedItemIds = [...enrollment.completedItems] + async toPrisma(enrollment: Enrollment): Promise { + const completedItemIds = await this.enrollmentCompletedItemsRepository.findAllByEnrollmentId(enrollment.id.toString()) return { id: enrollment.id.toString(), @@ -34,7 +30,7 @@ export class EnrollmentMapper { completedAt: enrollment.completedAt, ocurredAt: enrollment.ocurredAt, enrollmentCompletedItems: { - connect: completedItemIds.map(completedItemId => ({ id: completedItemId.toString() })) as unknown as Prisma.EnrollmentCompletedItemWhereUniqueInput[] + connect: completedItemIds.map(completedItem => ({ id: completedItem.id.toString() })) as unknown as Prisma.EnrollmentCompletedItemWhereUniqueInput[] } } } diff --git a/src/infra/database/prisma/mappers/factories/make-certificate-mapper.ts b/src/infra/database/prisma/mappers/factories/make-certificate-mapper.ts index 22e47a3..5ccbc66 100644 --- a/src/infra/database/prisma/mappers/factories/make-certificate-mapper.ts +++ b/src/infra/database/prisma/mappers/factories/make-certificate-mapper.ts @@ -1,13 +1,13 @@ -import { InMemoryImagesRepository } from '../../../../../../test/repositories/in-memory-images-repository' +import { PrismaImagesRepository } from '@/infra/database/prisma/repositories/prisma-images-repository' import { PrismaStudentCertificatesRepository } from '../../repositories/prisma-student-certificates-repository' import { CertificateMapper } from '../certificate-mapper' export function makeCertificateMapper() { - const inMemoryImagesRepository = new InMemoryImagesRepository() + const prismaImagesRepository = new PrismaImagesRepository() const prismaStudentCertificatesRepository = new PrismaStudentCertificatesRepository() const certificateMapper = new CertificateMapper( - inMemoryImagesRepository, + prismaImagesRepository, prismaStudentCertificatesRepository ) diff --git a/src/infra/database/prisma/mappers/factories/make-enrollment-mapper.ts b/src/infra/database/prisma/mappers/factories/make-enrollment-mapper.ts index 3f93961..0d43274 100644 --- a/src/infra/database/prisma/mappers/factories/make-enrollment-mapper.ts +++ b/src/infra/database/prisma/mappers/factories/make-enrollment-mapper.ts @@ -1,4 +1,4 @@ -import { EnrollmentMapper } from '../enrollment-mapeer' +import { EnrollmentMapper } from '../enrollment-mapper' import { PrismaEnrollmentCompleteItemsRepository } from './../../repositories/prisma-enrollment-completed-items-repository' export function makeEnrollmentMapper() { diff --git a/src/infra/database/prisma/mappers/image-mapper.ts b/src/infra/database/prisma/mappers/image-mapper.ts new file mode 100644 index 0000000..1b8788b --- /dev/null +++ b/src/infra/database/prisma/mappers/image-mapper.ts @@ -0,0 +1,34 @@ +import { UniqueEntityID } from '@/core/entities/unique-entity-id' +import { Image } from '@/domain/course-management/enterprise/entities/image' +import { type Prisma } from '@prisma/client' + +export class ImageMapper { + static toDomain(raw: Prisma.ImageGetPayload<{ include: { file: true } }>): Image | null { + if (!raw.file) { + return null + } + + if (raw.file.type !== 'image/jpeg' && raw.file.type !== 'image/png') { + return null + } + + return Image.create( + { + imageName: raw.file.name, + imageKey: raw.fileKey, + body: raw.file.body, + imageType: raw.file.type as 'image/jpeg' | 'image/png', + size: Number(raw.file.size), + storedAt: raw.file.storedAt + }, + new UniqueEntityID(raw.id) + ) + } + + static toPrisma(image: Image): Prisma.ImageUncheckedCreateInput { + return { + id: image.id.toString(), + fileKey: image.imageKey + } + } +} diff --git a/src/infra/database/prisma/mappers/user-mapper.ts b/src/infra/database/prisma/mappers/user-mapper.ts index 691c79c..28b712e 100644 --- a/src/infra/database/prisma/mappers/user-mapper.ts +++ b/src/infra/database/prisma/mappers/user-mapper.ts @@ -1,3 +1,4 @@ +import { UniqueEntityID } from '@/core/entities/unique-entity-id' import { type CoursesRepository } from '@/domain/course-management/application/repositories/courses-repository' import { type EnrollmentsRepository } from '@/domain/course-management/application/repositories/enrollments-repository' import { type EvaluationsRepository } from '@/domain/course-management/application/repositories/evaluations-repository' @@ -31,7 +32,8 @@ export class UserMapper { bannerImageKey: raw.bannerImageKey, profileImageKey: raw.profileImageKey, registeredAt: raw.registeredAt - }) + }, + new UniqueEntityID(raw.id)) } return Student.create({ @@ -44,7 +46,8 @@ export class UserMapper { bannerImageKey: raw.bannerImageKey, profileImageKey: raw.profileImageKey, registeredAt: raw.registeredAt - }) + }, + new UniqueEntityID(raw.id)) } async toPrisma(user: User): Promise { diff --git a/src/infra/database/prisma/mappers/video-mapper.ts b/src/infra/database/prisma/mappers/video-mapper.ts index 670c67f..9b36aa4 100644 --- a/src/infra/database/prisma/mappers/video-mapper.ts +++ b/src/infra/database/prisma/mappers/video-mapper.ts @@ -26,11 +26,7 @@ export class VideoMapper { ) } - static toPrisma(video: Video) { - if (!video.videoKey) { - return null - } - + static toPrisma(video: Video): Prisma.VideoUncheckedCreateInput { return { id: video.id.toString(), duration: video.duration, diff --git a/src/infra/database/prisma/repositories/prisma-enrollment-completed-items-repository.ts b/src/infra/database/prisma/repositories/prisma-enrollment-completed-items-repository.ts index 3a5f6ec..5221c66 100644 --- a/src/infra/database/prisma/repositories/prisma-enrollment-completed-items-repository.ts +++ b/src/infra/database/prisma/repositories/prisma-enrollment-completed-items-repository.ts @@ -20,6 +20,23 @@ export class PrismaEnrollmentCompleteItemsRepository implements EnrollmentComple return domainCompletedItem } + async findByEnrollmentIdAndItemId(enrollmentId: string, itemId: string): Promise { + const completedItem = await prisma.enrollmentCompletedItem.findFirst({ + where: { + enrollmentId, + itemId + } + }) + + if (!completedItem) { + return null + } + + const domainCompletedItem = EnrollmentCompletedItemMapper.toDomain(completedItem) + + return domainCompletedItem + } + async findManyCompletedClassesByEnrollmentId(enrollmentId: string): Promise { const completedItems = await prisma.enrollmentCompletedItem.findMany({ where: { @@ -67,4 +84,12 @@ export class PrismaEnrollmentCompleteItemsRepository implements EnrollmentComple return completedItem } + + async delete(completedItem: EnrollmentCompletedItem): Promise { + await prisma.enrollmentCompletedItem.delete({ + where: { + id: completedItem.id.toString() + } + }) + } } diff --git a/src/infra/database/prisma/repositories/prisma-enrollments-repository.ts b/src/infra/database/prisma/repositories/prisma-enrollments-repository.ts index 378d62c..e03cc3e 100644 --- a/src/infra/database/prisma/repositories/prisma-enrollments-repository.ts +++ b/src/infra/database/prisma/repositories/prisma-enrollments-repository.ts @@ -3,7 +3,7 @@ import { type Enrollment } from '@/domain/course-management/enterprise/entities/ import { type Student } from '@/domain/course-management/enterprise/entities/student' import { prisma } from '..' import { StudentMapper } from '../mappers/student-mapper' -import { type EnrollmentMapper } from './../mappers/enrollment-mapeer' +import { type EnrollmentMapper } from './../mappers/enrollment-mapper' export class PrismaEnrollmentsRepository implements EnrollmentsRepository { constructor( @@ -93,23 +93,6 @@ export class PrismaEnrollmentsRepository implements EnrollmentsRepository { return students.map(student => StudentMapper.toDomain(student)) } - async markItemAsCompleted(itemId: string, enrollment: Enrollment): Promise { - // TODO: This is wrong but I'm out of time, I'll refactor it in the future - const infraEnrollment = await prisma.enrollment.findUnique({ - where: { - id: enrollment.id.toString() - } - }) - - if (!infraEnrollment) { - return null - } - - const domainEnrollment = await this.enrollmentMapper.toDomain(infraEnrollment) - - return domainEnrollment - } - async markAsCompleted(enrollment: Enrollment): Promise { const infraEnrollment = await prisma.enrollment.update({ where: { diff --git a/src/infra/database/prisma/repositories/prisma-files-repository.ts b/src/infra/database/prisma/repositories/prisma-files-repository.ts index eb53215..a13071a 100644 --- a/src/infra/database/prisma/repositories/prisma-files-repository.ts +++ b/src/infra/database/prisma/repositories/prisma-files-repository.ts @@ -1,3 +1,4 @@ +import { DomainEvents } from '@/core/events/domain-events' import { type FilesRepository } from '@/domain/storage/application/repositories/files-repository' import { type File } from '@/domain/storage/enterprise/entities/file' import { prisma } from '..' @@ -47,6 +48,8 @@ export class PrismaFilesRepository implements FilesRepository { data: infraFile }) + DomainEvents.dispatchEventsForAggregate(file.id) + return file } } diff --git a/src/infra/database/prisma/repositories/prisma-images-repository.ts b/src/infra/database/prisma/repositories/prisma-images-repository.ts new file mode 100644 index 0000000..d006412 --- /dev/null +++ b/src/infra/database/prisma/repositories/prisma-images-repository.ts @@ -0,0 +1,80 @@ +import { type ImagesRepository } from '@/domain/course-management/application/repositories/images-repository' +import { type Image } from '@/domain/course-management/enterprise/entities/image' +import { prisma } from '..' +import { ImageMapper } from '../mappers/image-mapper' + +export class PrismaImagesRepository implements ImagesRepository { + async findById(id: string): Promise { + const image = await prisma.image.findUnique({ + where: { + id + }, + include: { + file: true + } + }) + + if (!image) { + return null + } + + const domainImage = ImageMapper.toDomain(image) + + return domainImage + } + + async findByImageKey(key: string): Promise { + const image = await prisma.image.findUnique({ + where: { + fileKey: key + }, + include: { + file: true + } + }) + + if (!image) { + return null + } + + const domainImage = ImageMapper.toDomain(image) + + return domainImage + } + + async appendImageKey(imageKey: string, imageId: string): Promise { + const image = await prisma.image.findUnique({ + where: { + id: imageId + }, + include: { + file: true + } + }) + + if (!image) { + return null + } + + await prisma.image.update({ + where: { id: imageId }, + data: { + fileKey: imageKey + } + }) + + const domainImage = ImageMapper.toDomain(image) + + return domainImage + } + + async create(image: Image): Promise { + const infraImage = ImageMapper.toPrisma(image) + + await prisma.image.create({ + data: infraImage + }) + + return image + } +} diff --git a/src/infra/database/prisma/repositories/prisma-videos-repository.ts b/src/infra/database/prisma/repositories/prisma-videos-repository.ts index 19fd545..cc62cec 100644 --- a/src/infra/database/prisma/repositories/prisma-videos-repository.ts +++ b/src/infra/database/prisma/repositories/prisma-videos-repository.ts @@ -1,4 +1,3 @@ -import { DomainEvents } from '@/core/events/domain-events' import { type VideosRepository } from '@/domain/course-management/application/repositories/videos-repository' import { type Video } from '@/domain/course-management/enterprise/entities/video' import { prisma } from '..' @@ -60,11 +59,7 @@ export class PrismaVideosRepository implements VideosRepository { await prisma.video.update({ where: { id: videoId }, data: { - file: { - update: { - key: videoKey - } - } + fileKey: videoKey } }) @@ -84,8 +79,6 @@ export class PrismaVideosRepository implements VideosRepository { data: infraVideo }) - DomainEvents.dispatchEventsForAggregate(video.id) - return video } } diff --git a/src/infra/env/index.ts b/src/infra/env/index.ts index c217954..23e2f49 100644 --- a/src/infra/env/index.ts +++ b/src/infra/env/index.ts @@ -3,7 +3,7 @@ import { z } from 'zod' config() -const envSchema = z.object({ +export const envSchema = z.object({ NODE_ENV: z.enum(['development', 'test', 'production']).default('production'), PORT: z.coerce.number().default(3333), DATABASE_URL: z.string(), diff --git a/src/infra/events/factories/make-on-file-uploaded.ts b/src/infra/events/factories/make-on-file-uploaded.ts new file mode 100644 index 0000000..859777d --- /dev/null +++ b/src/infra/events/factories/make-on-file-uploaded.ts @@ -0,0 +1,18 @@ +import { OnFileUploaded } from '@/domain/course-management/application/subscribers/on-file-uploaded' +import { PrismaImagesRepository } from '@/infra/database/prisma/repositories/prisma-images-repository' +import { PrismaVideosRepository } from '@/infra/database/prisma/repositories/prisma-videos-repository' +import { GetVideoDuration } from './../../storage/utils/get-video-duration' + +export function makeOnFileUploaded() { + const prismaImagesRepository = new PrismaImagesRepository() + const prismaVideosRepository = new PrismaVideosRepository() + const getVideoDuration = new GetVideoDuration() + + const onVideoKeyGenerated = new OnFileUploaded( + prismaImagesRepository, + prismaVideosRepository, + getVideoDuration + ) + + return onVideoKeyGenerated +} diff --git a/src/infra/http/controllers/add-class-to-module.ts b/src/infra/http/controllers/add-class-to-module.ts new file mode 100644 index 0000000..fe52398 --- /dev/null +++ b/src/infra/http/controllers/add-class-to-module.ts @@ -0,0 +1,58 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { ClassAlreadyExistsInThisModuleError } from '@/domain/course-management/application/use-cases/errors/class-already-exists-in-this-module-error' +import { ClassNumberIsAlreadyInUseError } from '@/domain/course-management/application/use-cases/errors/class-number-is-already-in-use-error' +import { ClassVideoRequiredError } from '@/domain/course-management/application/use-cases/errors/class-video-required-error' +import { makeAddClassToModuleUseCase } from '@/infra/use-cases/factories/make-add-class-to-module-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const addClassToModuleBodySchema = z.object({ + name: z.string(), + description: z.string(), + classNumber: z.number() +}) + +const addClassToModuleParamsSchema = z.object({ + moduleId: z.string(), + videoId: z.string() +}) + +export async function addClassToModuleController(request: FastifyRequest, reply: FastifyReply) { + const { name, description, classNumber } = addClassToModuleBodySchema.parse(request.body) + const { moduleId, videoId } = addClassToModuleParamsSchema.parse(request.params) + + const { sub: instructorId } = request.user + + const addClassToModuleUseCase = makeAddClassToModuleUseCase() + + const result = await addClassToModuleUseCase.exec({ + name, + description, + classNumber, + moduleId, + videoId, + instructorId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + case ClassAlreadyExistsInThisModuleError: + return await reply.status(409).send({ message: error.message }) + case ClassNumberIsAlreadyInUseError: + return await reply.status(409).send({ message: error.message }) + case ClassVideoRequiredError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(201).send() +} diff --git a/src/infra/http/controllers/attach-tags-to-course.ts b/src/infra/http/controllers/attach-tags-to-course.ts new file mode 100644 index 0000000..af67b52 --- /dev/null +++ b/src/infra/http/controllers/attach-tags-to-course.ts @@ -0,0 +1,49 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { RepeatedTagError } from '@/domain/course-management/application/use-cases/errors/repeated-tag-error' +import { TagAlreadyAttachedError } from '@/domain/course-management/application/use-cases/errors/tag-already-attached-error' +import { makeAttachTagToCourseUseCase } from '@/infra/use-cases/factories/make-attach-tag-to-course-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const attachTagsToCourseBodySchema = z.object({ + tagIds: z.array(z.string().uuid()) +}) + +const attachTagsToCourseParamsSchema = z.object({ + courseId: z.string() +}) + +export async function attachTagsToCourseController(request: FastifyRequest, reply: FastifyReply) { + const { tagIds } = attachTagsToCourseBodySchema.parse(request.body) + const { courseId } = attachTagsToCourseParamsSchema.parse(request.params) + + const { sub: instructorId } = request.user + + const attachTagsToCourseUseCase = makeAttachTagToCourseUseCase() + + const result = await attachTagsToCourseUseCase.exec({ + tagIds, + courseId, + instructorId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case RepeatedTagError: + return await reply.status(409).send({ message: error.message }) + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + case TagAlreadyAttachedError: + return await reply.status(409).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(201).send() +} diff --git a/src/infra/http/controllers/authenticate.ts b/src/infra/http/controllers/authenticate.ts new file mode 100644 index 0000000..19190c7 --- /dev/null +++ b/src/infra/http/controllers/authenticate.ts @@ -0,0 +1,43 @@ +import { WrongCredentialsError } from '@/core/errors/errors/wrong-credentials-error' +import { makeAuthenticateUserUseCase } from '@/infra/use-cases/factories/make-authenticate-user-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const authenticateUserBodySchema = z.object({ + email: z.string().email(), + password: z.string() +}) + +export async function authenticateUserController(request: FastifyRequest, reply: FastifyReply) { + const { email, password } = authenticateUserBodySchema.parse(request.body) + + const authenticateUserUseCase = makeAuthenticateUserUseCase(reply) + + const result = await authenticateUserUseCase.exec({ + email, + password + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case WrongCredentialsError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const { accessToken } = result.value + + return await + reply + .status(200) + .setCookie('spark.accesstoken', accessToken, { + maxAge: 60 * 60 * 24 // One day + }) + .send({ + accessToken + }) +} diff --git a/src/infra/http/controllers/cancel-enrollment.ts b/src/infra/http/controllers/cancel-enrollment.ts new file mode 100644 index 0000000..6facce0 --- /dev/null +++ b/src/infra/http/controllers/cancel-enrollment.ts @@ -0,0 +1,36 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeCancelEnrollmentUseCase } from '@/infra/use-cases/factories/make-cancel-enrollment-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const cancelEnrollmentParamsSchema = z.object({ + enrollmentId: z.string().uuid() +}) + +export async function cancelEnrollmentController(request: FastifyRequest, reply: FastifyReply) { + const { enrollmentId } = cancelEnrollmentParamsSchema.parse(request.params) + const { sub: studentId } = request.user + + const cancelEnrollmentUseCase = makeCancelEnrollmentUseCase() + + const result = await cancelEnrollmentUseCase.exec({ + enrollmentId, + studentId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(204).send() +} diff --git a/src/infra/http/controllers/controller.txt b/src/infra/http/controllers/controller.txt deleted file mode 100644 index 0704454..0000000 --- a/src/infra/http/controllers/controller.txt +++ /dev/null @@ -1 +0,0 @@ -Controller \ No newline at end of file diff --git a/src/infra/http/controllers/delete-class.ts b/src/infra/http/controllers/delete-class.ts new file mode 100644 index 0000000..1279d7f --- /dev/null +++ b/src/infra/http/controllers/delete-class.ts @@ -0,0 +1,36 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeDeleteClassUseCase } from '@/infra/use-cases/factories/make-delete-class-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const deleteClassParamsSchema = z.object({ + classId: z.string().uuid() +}) + +export async function deleteClassController(request: FastifyRequest, reply: FastifyReply) { + const { classId } = deleteClassParamsSchema.parse(request.params) + const { sub: instructorId } = request.user + + const DeleteClassUseCase = makeDeleteClassUseCase() + + const result = await DeleteClassUseCase.exec({ + classId, + instructorId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(204).send() +} diff --git a/src/infra/http/controllers/delete-course-certificate.ts b/src/infra/http/controllers/delete-course-certificate.ts new file mode 100644 index 0000000..8c8fb46 --- /dev/null +++ b/src/infra/http/controllers/delete-course-certificate.ts @@ -0,0 +1,38 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeDeleteCourseCertificateUseCase } from '@/infra/use-cases/factories/make-delete-course-certificate-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const deleteCourseCertificateParamsSchema = z.object({ + courseId: z.string().uuid(), + certificateId: z.string().uuid() +}) + +export async function deleteCourseCertificateController(request: FastifyRequest, reply: FastifyReply) { + const { courseId, certificateId } = deleteCourseCertificateParamsSchema.parse(request.params) + const { sub: instructorId } = request.user + + const DeleteCourseCertificateUseCase = makeDeleteCourseCertificateUseCase() + + const result = await DeleteCourseCertificateUseCase.exec({ + courseId, + certificateId, + instructorId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(204).send() +} diff --git a/src/infra/http/controllers/delete-course.ts b/src/infra/http/controllers/delete-course.ts new file mode 100644 index 0000000..4be5075 --- /dev/null +++ b/src/infra/http/controllers/delete-course.ts @@ -0,0 +1,36 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeDeleteCourseUseCase } from '@/infra/use-cases/factories/make-delete-course-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const deleteCourseParamsSchema = z.object({ + courseId: z.string().uuid() +}) + +export async function deleteCourseController(request: FastifyRequest, reply: FastifyReply) { + const { courseId } = deleteCourseParamsSchema.parse(request.params) + const { sub: instructorId } = request.user + + const DeleteCourseUseCase = makeDeleteCourseUseCase() + + const result = await DeleteCourseUseCase.exec({ + courseId, + instructorId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(204).send() +} diff --git a/src/infra/http/controllers/delete-module.ts b/src/infra/http/controllers/delete-module.ts new file mode 100644 index 0000000..5c76687 --- /dev/null +++ b/src/infra/http/controllers/delete-module.ts @@ -0,0 +1,36 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeDeleteModuleUseCase } from '@/infra/use-cases/factories/make-delete-module-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const deleteModuleParamsSchema = z.object({ + moduleId: z.string().uuid() +}) + +export async function deleteModuleController(request: FastifyRequest, reply: FastifyReply) { + const { moduleId } = deleteModuleParamsSchema.parse(request.params) + const { sub: instructorId } = request.user + + const DeleteModuleUseCase = makeDeleteModuleUseCase() + + const result = await DeleteModuleUseCase.exec({ + moduleId, + instructorId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(204).send() +} diff --git a/src/infra/http/controllers/delete-user.ts b/src/infra/http/controllers/delete-user.ts new file mode 100644 index 0000000..c7710a4 --- /dev/null +++ b/src/infra/http/controllers/delete-user.ts @@ -0,0 +1,24 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeDeleteUserUseCase } from '@/infra/use-cases/factories/make-delete-user-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' + +export async function deleteUserController(request: FastifyRequest, reply: FastifyReply) { + const DeleteUserUseCase = makeDeleteUserUseCase() + + const result = await DeleteUserUseCase.exec({ + userId: request.user.sub + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(204).send() +} diff --git a/src/infra/http/controllers/edit-class-details.ts b/src/infra/http/controllers/edit-class-details.ts new file mode 100644 index 0000000..47547f9 --- /dev/null +++ b/src/infra/http/controllers/edit-class-details.ts @@ -0,0 +1,61 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { ClassNumberIsAlreadyInUseError } from '@/domain/course-management/application/use-cases/errors/class-number-is-already-in-use-error' +import { makeClassMapper } from '@/infra/database/prisma/mappers/factories/make-class-mapper' +import { makeEditClassDetailsUseCase } from '@/infra/use-cases/factories/make-edit-class-details-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { ClassPresenter } from '../presenters/class-presenter' + +const editClassDetailsBodySchema = z.object({ + name: z.string().optional(), + description: z.string().optional(), + classNumber: z.number().optional(), + videoId: z.string().uuid().optional() +}) + +const editClassDetailsParamsSchema = z.object({ + classId: z.string().uuid() +}) + +export async function editClassDetailsController(request: FastifyRequest, reply: FastifyReply) { + const { name, description, classNumber, videoId } = editClassDetailsBodySchema.parse(request.body) + const { classId } = editClassDetailsParamsSchema.parse(request.params) + + const { sub: instructorId } = request.user + + const editClassDetailsUseCase = makeEditClassDetailsUseCase() + + const result = await editClassDetailsUseCase.exec({ + name, + description, + classNumber, + videoId, + classId, + instructorId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + case ClassNumberIsAlreadyInUseError: + return await reply.status(409).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const classMapper = makeClassMapper() + const { class: classToReply } = result.value + + const infraClass = await classMapper.toPrisma(classToReply) + + return await reply.status(200).send({ + class: ClassPresenter.toHTTP(infraClass) + }) +} diff --git a/src/infra/http/controllers/edit-course-details.ts b/src/infra/http/controllers/edit-course-details.ts new file mode 100644 index 0000000..5144517 --- /dev/null +++ b/src/infra/http/controllers/edit-course-details.ts @@ -0,0 +1,58 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeCourseMapper } from '@/infra/database/prisma/mappers/factories/make-course-mapper' +import { makeEditCourseDetailsUseCase } from '@/infra/use-cases/factories/make-edit-course-details-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { CoursePresenter } from '../presenters/course-presenter' + +const editCourseDetailsBodySchema = z.object({ + name: z.string().optional(), + description: z.string().optional(), + coverImageKey: z.string().optional(), + bannerImageKey: z.string().optional() +}) + +const editCourseDetailsParamsSchema = z.object({ + courseId: z.string().uuid() +}) + +export async function editCourseDetailsController(request: FastifyRequest, reply: FastifyReply) { + const { name, description, coverImageKey, bannerImageKey } = editCourseDetailsBodySchema.parse(request.body) + const { courseId } = editCourseDetailsParamsSchema.parse(request.params) + + const { sub: instructorId } = request.user + + const editCourseDetailsUseCase = makeEditCourseDetailsUseCase() + + const result = await editCourseDetailsUseCase.exec({ + name, + description, + coverImageKey, + bannerImageKey, + courseId, + instructorId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const courseMapper = makeCourseMapper() + const { course } = result.value + + const infraCourse = await courseMapper.toPrisma(course) + + return await reply.status(200).send({ + course: CoursePresenter.toHTTP(infraCourse) + }) +} diff --git a/src/infra/http/controllers/edit-evaluation-details.ts b/src/infra/http/controllers/edit-evaluation-details.ts new file mode 100644 index 0000000..e877a77 --- /dev/null +++ b/src/infra/http/controllers/edit-evaluation-details.ts @@ -0,0 +1,51 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { EvaluationMapper } from '@/infra/database/prisma/mappers/evaluation-mapper' +import { makeEditEvaluationDetailsUseCase } from '@/infra/use-cases/factories/make-edit-evaluation-details-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { EvaluationPresenter } from '../presenters/evaluation-presenter' + +const editEvaluationDetailsBodySchema = z.object({ + value: z.number().min(1).max(5) +}) + +const editEvaluationDetailsParamsSchema = z.object({ + evaluationId: z.string().uuid() +}) + +export async function editEvaluationDetailsController(request: FastifyRequest, reply: FastifyReply) { + const { value } = editEvaluationDetailsBodySchema.parse(request.body) + const { evaluationId } = editEvaluationDetailsParamsSchema.parse(request.params) + + const { sub: studentId } = request.user + + const editEvaluationDetailsUseCase = makeEditEvaluationDetailsUseCase() + + const result = await editEvaluationDetailsUseCase.exec({ + value, + evaluationId, + studentId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const { evaluation } = result.value + + const infraEvaluation = EvaluationMapper.toPrisma(evaluation) + + return await reply.status(200).send({ + evaluation: EvaluationPresenter.toHTTP(infraEvaluation) + }) +} diff --git a/src/infra/http/controllers/edit-module-details.ts b/src/infra/http/controllers/edit-module-details.ts new file mode 100644 index 0000000..f8f4679 --- /dev/null +++ b/src/infra/http/controllers/edit-module-details.ts @@ -0,0 +1,59 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { ModuleNumberIsAlreadyInUseError } from '@/domain/course-management/application/use-cases/errors/module-number-already-in-use-error' +import { makeModuleMapper } from '@/infra/database/prisma/mappers/factories/make-module-mapper' +import { makeEditModuleDetailsUseCase } from '@/infra/use-cases/factories/make-edit-module-details-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { ModulePresenter } from '../presenters/module-presenter' + +const editModuleDetailsBodySchema = z.object({ + name: z.string().optional(), + description: z.string().optional(), + moduleNumber: z.number().optional() +}) + +const editModuleDetailsParamsSchema = z.object({ + moduleId: z.string().uuid() +}) + +export async function editModuleDetailsController(request: FastifyRequest, reply: FastifyReply) { + const { name, description, moduleNumber } = editModuleDetailsBodySchema.parse(request.body) + const { moduleId } = editModuleDetailsParamsSchema.parse(request.params) + + const { sub: instructorId } = request.user + + const editModuleDetailsUseCase = makeEditModuleDetailsUseCase() + + const result = await editModuleDetailsUseCase.exec({ + name, + description, + moduleNumber, + moduleId, + instructorId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + case ModuleNumberIsAlreadyInUseError: + return await reply.status(409).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const moduleMapper = makeModuleMapper() + const { module } = result.value + + const infraModule = await moduleMapper.toPrisma(module) + + return await reply.status(200).send({ + module: ModulePresenter.toHTTP(infraModule) + }) +} diff --git a/src/infra/http/controllers/edit-user-details.ts b/src/infra/http/controllers/edit-user-details.ts new file mode 100644 index 0000000..189be73 --- /dev/null +++ b/src/infra/http/controllers/edit-user-details.ts @@ -0,0 +1,87 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeInstructorMapper } from '@/infra/database/prisma/mappers/factories/make-instructor-mapper' +import { makeStudentMapper } from '@/infra/database/prisma/mappers/factories/make-student-mapper' +import { makeEditInstructorDetailsUseCase } from '@/infra/use-cases/factories/make-edit-instructor-details-use-case' +import { makeEditStudentDetailsUseCase } from '@/infra/use-cases/factories/make-edit-student-details-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { UserPresenter } from '../presenters/user-presenter' + +const editUserDetailsBodySchema = z.object({ + email: z.string().email().optional(), + age: z.number().optional(), + summary: z.string().optional(), + profileImageKey: z.string().optional(), + bannerImageKey: z.string().optional() +}) + +export async function editUserDetailsController(request: FastifyRequest, reply: FastifyReply) { + const { email, age, summary, bannerImageKey, profileImageKey } = editUserDetailsBodySchema.parse(request.body) + const { role } = request.user + + if (role === 'STUDENT') { + const editStudentDetailsUseCase = makeEditStudentDetailsUseCase() + + const result = await editStudentDetailsUseCase.exec({ + email, + age, + summary, + bannerImageKey, + profileImageKey, + studentId: request.user.sub + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const studentMapper = makeStudentMapper() + const { student } = result.value + + const infraUser = await studentMapper.toPrisma(student) + + return await reply.status(200).send({ + user: UserPresenter.toHTTP(infraUser) + }) + } + + if (role === 'INSTRUCTOR') { + const EditInstructorDetailsUseCase = makeEditInstructorDetailsUseCase() + + const result = await EditInstructorDetailsUseCase.exec({ + email, + age, + summary, + bannerImageKey, + profileImageKey, + instructorId: request.user.sub + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const instructorMapper = makeInstructorMapper() + const { instructor } = result.value + + const infraUser = await instructorMapper.toPrisma(instructor) + + return await reply.status(200).send({ + user: UserPresenter.toHTTP(infraUser) + }) + } +} diff --git a/src/infra/http/controllers/enroll-to-course.ts b/src/infra/http/controllers/enroll-to-course.ts new file mode 100644 index 0000000..6cd50ed --- /dev/null +++ b/src/infra/http/controllers/enroll-to-course.ts @@ -0,0 +1,36 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { AlreadyEnrolledInThisCourse } from '@/domain/course-management/application/use-cases/errors/already-enrolled-in-this-course' +import { makeEnrollToCourseUseCase } from '@/infra/use-cases/factories/make-enroll-to-course-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const enrollToCourseParamsSchema = z.object({ + courseId: z.string().uuid() +}) + +export async function enrollToCourseController(request: FastifyRequest, reply: FastifyReply) { + const { courseId } = enrollToCourseParamsSchema.parse(request.params) + const { sub: studentId } = request.user + + const enrollToCourseUseCase = makeEnrollToCourseUseCase() + + const result = await enrollToCourseUseCase.exec({ + studentId, + courseId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case AlreadyEnrolledInThisCourse: + return await reply.status(409).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(201).send() +} diff --git a/src/infra/http/controllers/evaluate-class.ts b/src/infra/http/controllers/evaluate-class.ts new file mode 100644 index 0000000..39d2629 --- /dev/null +++ b/src/infra/http/controllers/evaluate-class.ts @@ -0,0 +1,59 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { InvalidEvaluationValueError } from '@/domain/course-management/application/use-cases/errors/invalid-evaluation-value-error' +import { StudentAlreadyEvaluateThisClassError } from '@/domain/course-management/application/use-cases/errors/student-already-evaluate-this-class-error' +import { StudentMustBeRegisteredToEvaluateError } from '@/domain/course-management/application/use-cases/errors/student-must-be-registered-to-evaluate-error' +import { EvaluationMapper } from '@/infra/database/prisma/mappers/evaluation-mapper' +import { makeEvaluateClassUseCase } from '@/infra/use-cases/factories/make-evaluate-class-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { EvaluationPresenter } from '../presenters/evaluation-presenter' + +const evaluateClassBodySchema = z.object({ + value: z.number().min(1).max(5) +}) + +const evaluateClassParamsSchema = z.object({ + courseId: z.string().uuid(), + classId: z.string().uuid() +}) + +export async function evaluateClassController(request: FastifyRequest, reply: FastifyReply) { + const { value } = evaluateClassBodySchema.parse(request.body) + const { courseId, classId } = evaluateClassParamsSchema.parse(request.params) + + const { sub: studentId } = request.user + + const evaluateClassUseCase = makeEvaluateClassUseCase() + + const result = await evaluateClassUseCase.exec({ + value, + studentId, + courseId, + classId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case InvalidEvaluationValueError: + return await reply.status(403).send({ message: error.message }) + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case StudentAlreadyEvaluateThisClassError: + return await reply.status(409).send({ message: error.message }) + case StudentMustBeRegisteredToEvaluateError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const { evaluation } = result.value + + const infraEvaluation = EvaluationMapper.toPrisma(evaluation) + + return await reply.status(200).send({ + evaluation: EvaluationPresenter.toHTTP(infraEvaluation) + }) +} diff --git a/src/infra/http/controllers/fetch-course-classes.ts b/src/infra/http/controllers/fetch-course-classes.ts new file mode 100644 index 0000000..28c59ef --- /dev/null +++ b/src/infra/http/controllers/fetch-course-classes.ts @@ -0,0 +1,45 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type Class } from '@/domain/course-management/enterprise/entities/class' +import { makeClassMapper } from '@/infra/database/prisma/mappers/factories/make-class-mapper' +import { makeFetchCourseClassesUseCase } from '@/infra/use-cases/factories/make-fetch-course-classes-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { ClassPresenter } from '../presenters/class-presenter' + +const fetchCourseClassesParamsSchema = z.object({ + courseId: z.string().uuid() +}) + +export async function fetchCourseClassesController(request: FastifyRequest, reply: FastifyReply) { + const { courseId } = fetchCourseClassesParamsSchema.parse(request.params) + + const fetchCourseClassesUseCase = makeFetchCourseClassesUseCase() + + const result = await fetchCourseClassesUseCase.exec({ + courseId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const classMapper = makeClassMapper() + const { classes } = result.value + + const infraClasses = await Promise.all( + classes.map(async (classToMap: Class) => { + return await classMapper.toPrisma(classToMap) + }) + ) + + return await reply.status(200).send({ + classes: infraClasses.map(infraClass => ClassPresenter.toHTTP(infraClass)) + }) +} diff --git a/src/infra/http/controllers/fetch-course-modules.ts b/src/infra/http/controllers/fetch-course-modules.ts new file mode 100644 index 0000000..672a89d --- /dev/null +++ b/src/infra/http/controllers/fetch-course-modules.ts @@ -0,0 +1,45 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type Module } from '@/domain/course-management/enterprise/entities/module' +import { makeModuleMapper } from '@/infra/database/prisma/mappers/factories/make-module-mapper' +import { makeFetchCourseModulesUseCase } from '@/infra/use-cases/factories/make-fetch-course-modules' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { ModulePresenter } from '../presenters/module-presenter' + +const fetchCourseModulesParamsSchema = z.object({ + courseId: z.string().uuid() +}) + +export async function fetchCourseModulesController(request: FastifyRequest, reply: FastifyReply) { + const { courseId } = fetchCourseModulesParamsSchema.parse(request.params) + + const fetchCourseModulesUseCase = makeFetchCourseModulesUseCase() + + const result = await fetchCourseModulesUseCase.exec({ + courseId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const moduleMapper = makeModuleMapper() + const { modules } = result.value + + const infraModules = await Promise.all( + modules.map(async (module: Module) => { + return await moduleMapper.toPrisma(module) + }) + ) + + return await reply.status(200).send({ + modules: infraModules.map(infraModule => ModulePresenter.toHTTP(infraModule)) + }) +} diff --git a/src/infra/http/controllers/fetch-course-students.ts b/src/infra/http/controllers/fetch-course-students.ts new file mode 100644 index 0000000..06a3c0f --- /dev/null +++ b/src/infra/http/controllers/fetch-course-students.ts @@ -0,0 +1,45 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type Student } from '@/domain/course-management/enterprise/entities/student' +import { makeStudentMapper } from '@/infra/database/prisma/mappers/factories/make-student-mapper' +import { makeGetCourseWithStudentsUseCase } from '@/infra/use-cases/factories/make-get-course-with-students-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { UserPresenter } from '../presenters/user-presenter' + +const fetchCourseStudentsParamsSchema = z.object({ + courseId: z.string().uuid() +}) + +export async function fetchCourseStudentsController(request: FastifyRequest, reply: FastifyReply) { + const { courseId } = fetchCourseStudentsParamsSchema.parse(request.params) + + const fetchCourseStudentsUseCase = makeGetCourseWithStudentsUseCase() + + const result = await fetchCourseStudentsUseCase.exec({ + courseId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const studentMapper = makeStudentMapper() + const students = result.value.courseWithStudents.students + + const infraStudents = await Promise.all( + students.map(async (student: Student) => { + return await studentMapper.toPrisma(student) + }) + ) + + return await reply.status(200).send({ + students: infraStudents.map(infraStudent => UserPresenter.toHTTP(infraStudent)) + }) +} diff --git a/src/infra/http/controllers/fetch-course-tags.ts b/src/infra/http/controllers/fetch-course-tags.ts new file mode 100644 index 0000000..f769c2d --- /dev/null +++ b/src/infra/http/controllers/fetch-course-tags.ts @@ -0,0 +1,45 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type Tag } from '@/domain/course-management/enterprise/entities/tag' +import { makeTagMapper } from '@/infra/database/prisma/mappers/factories/make-tag-mapper' +import { makeFetchCourseTagsUseCase } from '@/infra/use-cases/factories/make-fetch-course-tags-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { TagPresenter } from '../presenters/tag-presenter' + +const fetchCourseTagsParamsSchema = z.object({ + courseId: z.string().uuid() +}) + +export async function fetchCourseTagsController(request: FastifyRequest, reply: FastifyReply) { + const { courseId } = fetchCourseTagsParamsSchema.parse(request.params) + + const fetchCourseTagsUseCase = makeFetchCourseTagsUseCase() + + const result = await fetchCourseTagsUseCase.exec({ + courseId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const tagMapper = makeTagMapper() + const { tags } = result.value + + const infraTags = await Promise.all( + tags.map(async (tag: Tag) => { + return await tagMapper.toPrisma(tag) + }) + ) + + return await reply.status(200).send({ + tags: infraTags.map(infraTag => TagPresenter.toHTTP(infraTag)) + }) +} diff --git a/src/infra/http/controllers/fetch-enrollment-completed-classes.ts b/src/infra/http/controllers/fetch-enrollment-completed-classes.ts new file mode 100644 index 0000000..8b22a3c --- /dev/null +++ b/src/infra/http/controllers/fetch-enrollment-completed-classes.ts @@ -0,0 +1,43 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { EnrollmentCompletedItemMapper } from '@/infra/database/prisma/mappers/enrollment-completed-item-mapper' +import { makeFetchEnrollmentCompletedClassesUseCase } from '@/infra/use-cases/factories/make-fetch-enrollment-completed-classes-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { EnrollmentCompletedItemPresenter } from '../presenters/enrollment-completed-item-presenter' + +const fetchEnrollmentCompletedClassesParamsSchema = z.object({ + enrollmentId: z.string().uuid() +}) + +export async function fetchEnrollmentCompletedClassesController(request: FastifyRequest, reply: FastifyReply) { + const { enrollmentId } = fetchEnrollmentCompletedClassesParamsSchema.parse(request.params) + + const fetchEnrollmentCompletedClassesUseCase = makeFetchEnrollmentCompletedClassesUseCase() + + const result = await fetchEnrollmentCompletedClassesUseCase.exec({ + enrollmentId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const completedClasses = result.value.completedClasses + + const infraCompletedClasses = completedClasses.map(completedClass => { + return EnrollmentCompletedItemMapper.toPrisma(completedClass) + }) + + return await reply.status(200).send({ + completedClasses: infraCompletedClasses.map(infraCompletedClass => { + return EnrollmentCompletedItemPresenter.toHTTP(infraCompletedClass) + }) + }) +} diff --git a/src/infra/http/controllers/fetch-enrollment-completed-modules.ts b/src/infra/http/controllers/fetch-enrollment-completed-modules.ts new file mode 100644 index 0000000..821d92c --- /dev/null +++ b/src/infra/http/controllers/fetch-enrollment-completed-modules.ts @@ -0,0 +1,43 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { EnrollmentCompletedItemMapper } from '@/infra/database/prisma/mappers/enrollment-completed-item-mapper' +import { makeFetchEnrollmentCompletedModulesUseCase } from '@/infra/use-cases/factories/make-fetch-enrollment-completed-modules-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { EnrollmentCompletedItemPresenter } from '../presenters/enrollment-completed-item-presenter' + +const fetchEnrollmentCompletedModulesParamsSchema = z.object({ + enrollmentId: z.string().uuid() +}) + +export async function fetchEnrollmentCompletedModulesController(request: FastifyRequest, reply: FastifyReply) { + const { enrollmentId } = fetchEnrollmentCompletedModulesParamsSchema.parse(request.params) + + const fetchEnrollmentCompletedModulesUseCase = makeFetchEnrollmentCompletedModulesUseCase() + + const result = await fetchEnrollmentCompletedModulesUseCase.exec({ + enrollmentId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const completedModules = result.value.completedModules + + const infraCompletedModules = completedModules.map(completedModule => { + return EnrollmentCompletedItemMapper.toPrisma(completedModule) + }) + + return await reply.status(200).send({ + completedModules: infraCompletedModules.map(infraCompletedModule => { + return EnrollmentCompletedItemPresenter.toHTTP(infraCompletedModule) + }) + }) +} diff --git a/src/infra/http/controllers/fetch-instructor-courses.ts b/src/infra/http/controllers/fetch-instructor-courses.ts new file mode 100644 index 0000000..506e335 --- /dev/null +++ b/src/infra/http/controllers/fetch-instructor-courses.ts @@ -0,0 +1,93 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type CourseWithInstructorAndEvaluationDTO } from '@/domain/course-management/enterprise/entities/dtos/course-with-instructor-and-evaluation' +import { CourseDtoMapper } from '@/domain/course-management/enterprise/entities/dtos/mappers/course-dto-mapper' +import { makeGetCourseEvaluationsAverageUseCase } from '@/infra/use-cases/factories/make-get-course-evaluations-average-use-case' +import { makeGetInstructorWithCoursesUseCase } from '@/infra/use-cases/factories/make-get-instructor-with-courses-use-case' +import { makeGetUserInfoUseCase } from '@/infra/use-cases/factories/make-get-user-info-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { CoursesWithInstructorAndEvaluationPresenter } from '../presenters/courses-with-instructor-and-evaluation-presenter' + +const fetchInstructorCoursesParamsSchema = z.object({ + instructorId: z.string().uuid() +}) + +export async function fetchInstructorCoursesController(request: FastifyRequest, reply: FastifyReply) { + const { instructorId } = fetchInstructorCoursesParamsSchema.parse(request.params) + + const fetchInstructorCoursesUseCase = makeGetInstructorWithCoursesUseCase() + const getUserInfoUseCase = makeGetUserInfoUseCase() + const getCourseEvaluationsAverageUseCase = makeGetCourseEvaluationsAverageUseCase() + + const result = await fetchInstructorCoursesUseCase.exec({ + instructorId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const courses = result.value.instructorWithCourses.courses + + const coursesWithInstructorAndEvaluation: CourseWithInstructorAndEvaluationDTO[] = [] + + await Promise.all( + courses.map(async (course) => { + const instructorResult = await getUserInfoUseCase.exec({ + id: course.instructorId.toString() + }) + const courseEvaluationAverageResult = await getCourseEvaluationsAverageUseCase.exec({ + courseId: course.id.toString() + }) + + if (instructorResult.isLeft()) { + const error = instructorResult.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + if (courseEvaluationAverageResult.isLeft()) { + return await reply.status(500).send() + } + + const { user } = instructorResult.value + const { evaluationsAverage } = courseEvaluationAverageResult.value + + const courseWithInstructorAndEvaluation: CourseWithInstructorAndEvaluationDTO = { + course: CourseDtoMapper.toDTO(course), + instructor: { + id: user.id, + name: user.name, + email: user.email, + age: user.age, + registeredAt: user.registeredAt, + summary: user.summary, + bannerImageKey: user.bannerImageKey, + profileImageKey: user.profileImageKey + }, + evaluationsAverage + } + + coursesWithInstructorAndEvaluation.push(courseWithInstructorAndEvaluation) + }) + ) + + return await reply.status(200).send({ + courses: coursesWithInstructorAndEvaluation.map(courseWithInstructorAndEvaluation => + CoursesWithInstructorAndEvaluationPresenter.toHTTP(courseWithInstructorAndEvaluation + ) + ) + }) +} diff --git a/src/infra/http/controllers/fetch-module-classes.ts b/src/infra/http/controllers/fetch-module-classes.ts new file mode 100644 index 0000000..adec2e5 --- /dev/null +++ b/src/infra/http/controllers/fetch-module-classes.ts @@ -0,0 +1,45 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type Class } from '@/domain/course-management/enterprise/entities/class' +import { makeClassMapper } from '@/infra/database/prisma/mappers/factories/make-class-mapper' +import { makeFetchModuleClassesUseCase } from '@/infra/use-cases/factories/make-fetch-module-classes-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { ClassPresenter } from '../presenters/class-presenter' + +const fetchModuleClassesParamsSchema = z.object({ + moduleId: z.string().uuid() +}) + +export async function fetchModuleClassesController(request: FastifyRequest, reply: FastifyReply) { + const { moduleId } = fetchModuleClassesParamsSchema.parse(request.params) + + const fetchModuleClassesUseCase = makeFetchModuleClassesUseCase() + + const result = await fetchModuleClassesUseCase.exec({ + moduleId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const classMapper = makeClassMapper() + const { classes } = result.value + + const infraClasses = await Promise.all( + classes.map(async (classToMap: Class) => { + return await classMapper.toPrisma(classToMap) + }) + ) + + return await reply.status(200).send({ + classes: infraClasses.map(infraClass => ClassPresenter.toHTTP(infraClass)) + }) +} diff --git a/src/infra/http/controllers/fetch-recent-courses.ts b/src/infra/http/controllers/fetch-recent-courses.ts new file mode 100644 index 0000000..5b032b4 --- /dev/null +++ b/src/infra/http/controllers/fetch-recent-courses.ts @@ -0,0 +1,28 @@ +import { type Course } from '@/domain/course-management/enterprise/entities/course' +import { makeCourseMapper } from '@/infra/database/prisma/mappers/factories/make-course-mapper' +import { makeFetchRecentCoursesUseCase } from '@/infra/use-cases/factories/make-fetch-recent-courses-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { CoursePresenter } from '../presenters/course-presenter' + +export async function fetchRecentCoursesController(request: FastifyRequest, reply: FastifyReply) { + const fetchRecentCoursesUseCase = makeFetchRecentCoursesUseCase() + + const result = await fetchRecentCoursesUseCase.exec() + + if (result.isLeft()) { + return await reply.status(500).send() + } + + const courseMapper = makeCourseMapper() + const { courses } = result.value + + const infraCourses = await Promise.all( + courses.map(async (course: Course) => { + return await courseMapper.toPrisma(course) + }) + ) + + return await reply.status(200).send({ + courses: infraCourses.map(infraCourse => CoursePresenter.toHTTP(infraCourse)) + }) +} diff --git a/src/infra/http/controllers/fetch-recent-tags.ts b/src/infra/http/controllers/fetch-recent-tags.ts new file mode 100644 index 0000000..d1b5b06 --- /dev/null +++ b/src/infra/http/controllers/fetch-recent-tags.ts @@ -0,0 +1,28 @@ +import { type Tag } from '@/domain/course-management/enterprise/entities/tag' +import { makeTagMapper } from '@/infra/database/prisma/mappers/factories/make-tag-mapper' +import { makeFetchRecentTagsUseCase } from '@/infra/use-cases/factories/make-fetch-recent-tags-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { TagPresenter } from '../presenters/tag-presenter' + +export async function fetchRecentTagsController(request: FastifyRequest, reply: FastifyReply) { + const fetchRecentTagsUseCase = makeFetchRecentTagsUseCase() + + const result = await fetchRecentTagsUseCase.exec() + + if (result.isLeft()) { + return await reply.status(500).send() + } + + const tagMapper = makeTagMapper() + const { tags } = result.value + + const infraTags = await Promise.all( + tags.map(async (tag: Tag) => { + return await tagMapper.toPrisma(tag) + }) + ) + + return await reply.status(200).send({ + tags: infraTags.map(infraTag => TagPresenter.toHTTP(infraTag)) + }) +} diff --git a/src/infra/http/controllers/fetch-student-courses.ts b/src/infra/http/controllers/fetch-student-courses.ts new file mode 100644 index 0000000..6b15b3f --- /dev/null +++ b/src/infra/http/controllers/fetch-student-courses.ts @@ -0,0 +1,93 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type CourseWithInstructorAndEvaluationDTO } from '@/domain/course-management/enterprise/entities/dtos/course-with-instructor-and-evaluation' +import { CourseDtoMapper } from '@/domain/course-management/enterprise/entities/dtos/mappers/course-dto-mapper' +import { makeGetCourseEvaluationsAverageUseCase } from '@/infra/use-cases/factories/make-get-course-evaluations-average-use-case' +import { makeGetStudentWithCoursesUseCase } from '@/infra/use-cases/factories/make-get-student-with-courses-use-case' +import { makeGetUserInfoUseCase } from '@/infra/use-cases/factories/make-get-user-info-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { CoursesWithInstructorAndEvaluationPresenter } from '../presenters/courses-with-instructor-and-evaluation-presenter' + +const fetchStudentCoursesParamsSchema = z.object({ + studentId: z.string().uuid() +}) + +export async function fetchStudentCoursesController(request: FastifyRequest, reply: FastifyReply) { + const { studentId } = fetchStudentCoursesParamsSchema.parse(request.params) + + const fetchStudentCoursesUseCase = makeGetStudentWithCoursesUseCase() + const getUserInfoUseCase = makeGetUserInfoUseCase() + const getCourseEvaluationsAverageUseCase = makeGetCourseEvaluationsAverageUseCase() + + const result = await fetchStudentCoursesUseCase.exec({ + studentId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const courses = result.value.studentWithCourses.courses + + const coursesWithInstructorAndEvaluation: CourseWithInstructorAndEvaluationDTO[] = [] + + await Promise.all( + courses.map(async (course) => { + const instructorResult = await getUserInfoUseCase.exec({ + id: course.instructorId.toString() + }) + const courseEvaluationAverageResult = await getCourseEvaluationsAverageUseCase.exec({ + courseId: course.id.toString() + }) + + if (instructorResult.isLeft()) { + const error = instructorResult.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + if (courseEvaluationAverageResult.isLeft()) { + return await reply.status(500).send() + } + + const { user } = instructorResult.value + const { evaluationsAverage } = courseEvaluationAverageResult.value + + const courseWithInstructorAndEvaluation: CourseWithInstructorAndEvaluationDTO = { + course: CourseDtoMapper.toDTO(course), + instructor: { + id: user.id, + name: user.name, + email: user.email, + age: user.age, + registeredAt: user.registeredAt, + summary: user.summary, + bannerImageKey: user.bannerImageKey, + profileImageKey: user.profileImageKey + }, + evaluationsAverage + } + + coursesWithInstructorAndEvaluation.push(courseWithInstructorAndEvaluation) + }) + ) + + return await reply.status(200).send({ + courses: coursesWithInstructorAndEvaluation.map(courseWithInstructorAndEvaluation => + CoursesWithInstructorAndEvaluationPresenter.toHTTP(courseWithInstructorAndEvaluation + ) + ) + }) +} diff --git a/src/infra/http/controllers/get-course-details.ts b/src/infra/http/controllers/get-course-details.ts new file mode 100644 index 0000000..a138419 --- /dev/null +++ b/src/infra/http/controllers/get-course-details.ts @@ -0,0 +1,38 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeCourseMapper } from '@/infra/database/prisma/mappers/factories/make-course-mapper' +import { makeGetCourseDetailsUseCase } from '@/infra/use-cases/factories/make-get-course-details-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { CoursePresenter } from '../presenters/course-presenter' + +const getCourseDetailsParamsSchema = z.object({ + courseId: z.string().uuid() +}) + +export async function getCourseDetailsController(request: FastifyRequest, reply: FastifyReply) { + const { courseId } = getCourseDetailsParamsSchema.parse(request.params) + + const getCourseDetailsUseCase = makeGetCourseDetailsUseCase() + + const result = await getCourseDetailsUseCase.exec({ + courseId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const courseMapper = makeCourseMapper() + const course = await courseMapper.toPrisma(result.value.course) + + return await reply.status(200).send({ + course: CoursePresenter.toHTTP(course) + }) +} diff --git a/src/infra/http/controllers/get-course-evaluations-average.ts b/src/infra/http/controllers/get-course-evaluations-average.ts new file mode 100644 index 0000000..6671af8 --- /dev/null +++ b/src/infra/http/controllers/get-course-evaluations-average.ts @@ -0,0 +1,25 @@ +import { makeGetCourseEvaluationsAverageUseCase } from '@/infra/use-cases/factories/make-get-course-evaluations-average-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const getCourseEvaluationsAverageParamsSchema = z.object({ + courseId: z.string().uuid() +}) + +export async function getCourseEvaluationsAverageController(request: FastifyRequest, reply: FastifyReply) { + const { courseId } = getCourseEvaluationsAverageParamsSchema.parse(request.params) + + const getCourseEvaluationsAverageUseCase = makeGetCourseEvaluationsAverageUseCase() + + const result = await getCourseEvaluationsAverageUseCase.exec({ + courseId + }) + + if (result.isLeft()) { + return await reply.status(500).send() + } + + return await reply.status(200).send({ + evaluationsAverage: result.value.evaluationsAverage.toFixed(2) + }) +} diff --git a/src/infra/http/controllers/get-course-instructor-details.ts b/src/infra/http/controllers/get-course-instructor-details.ts new file mode 100644 index 0000000..1aee5ff --- /dev/null +++ b/src/infra/http/controllers/get-course-instructor-details.ts @@ -0,0 +1,38 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeInstructorMapper } from '@/infra/database/prisma/mappers/factories/make-instructor-mapper' +import { makeGetCourseInstructorDetailsUseCase } from '@/infra/use-cases/factories/make-get-course-instructor-details-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { UserPresenter } from '../presenters/user-presenter' + +const getCourseInstructorDetailsParamsSchema = z.object({ + courseId: z.string().uuid() +}) + +export async function getCourseInstructorDetailsController(request: FastifyRequest, reply: FastifyReply) { + const { courseId } = getCourseInstructorDetailsParamsSchema.parse(request.params) + + const getCourseInstructorDetailsUseCase = makeGetCourseInstructorDetailsUseCase() + + const result = await getCourseInstructorDetailsUseCase.exec({ + courseId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const instructorMapper = makeInstructorMapper() + const instructor = await instructorMapper.toPrisma(result.value.instructor) + + return await reply.status(200).send({ + instructor: UserPresenter.toHTTP(instructor) + }) +} diff --git a/src/infra/http/controllers/get-course-metrics.ts b/src/infra/http/controllers/get-course-metrics.ts new file mode 100644 index 0000000..3ea2c94 --- /dev/null +++ b/src/infra/http/controllers/get-course-metrics.ts @@ -0,0 +1,40 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeGetCourseMetricsUseCase } from '@/infra/use-cases/factories/make-get-course-metrics-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const getCourseMetricsParamsSchema = z.object({ + courseId: z.string().uuid() +}) + +export async function getCourseMetricsController(request: FastifyRequest, reply: FastifyReply) { + const { courseId } = getCourseMetricsParamsSchema.parse(request.params) + const { sub: instructorId } = request.user + + const getCourseMetricsUseCase = makeGetCourseMetricsUseCase() + + const result = await getCourseMetricsUseCase.exec({ + courseId, + instructorId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(200).send({ + metrics: { + ...result.value + } + }) +} diff --git a/src/infra/http/controllers/get-course-stats.ts b/src/infra/http/controllers/get-course-stats.ts new file mode 100644 index 0000000..76a1ff8 --- /dev/null +++ b/src/infra/http/controllers/get-course-stats.ts @@ -0,0 +1,35 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeGetCourseStatsUseCase } from '@/infra/use-cases/factories/make-get-course-stats-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const getCourseStatsParamsSchema = z.object({ + courseId: z.string().uuid() +}) + +export async function getCourseStatsController(request: FastifyRequest, reply: FastifyReply) { + const { courseId } = getCourseStatsParamsSchema.parse(request.params) + + const getCourseStatsUseCase = makeGetCourseStatsUseCase() + + const result = await getCourseStatsUseCase.exec({ + courseId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(200).send({ + stats: { + ...result.value + } + }) +} diff --git a/src/infra/http/controllers/get-enrollment-details.ts b/src/infra/http/controllers/get-enrollment-details.ts new file mode 100644 index 0000000..5e5b25c --- /dev/null +++ b/src/infra/http/controllers/get-enrollment-details.ts @@ -0,0 +1,42 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeEnrollmentMapper } from '@/infra/database/prisma/mappers/factories/make-enrollment-mapper' +import { makeGetEnrollmentDetailsUseCase } from '@/infra/use-cases/factories/make-get-enrollment-details-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { EnrollmentPresenter } from '../presenters/enrollment-presenter' + +const getEnrollmentDetailsParamsSchema = z.object({ + courseId: z.string().uuid(), + studentId: z.string().uuid() +}) + +export async function getEnrollmentDetailsController(request: FastifyRequest, reply: FastifyReply) { + const { courseId, studentId } = getEnrollmentDetailsParamsSchema.parse(request.params) + + const getEnrollmentDetailsUseCase = makeGetEnrollmentDetailsUseCase() + + const result = await getEnrollmentDetailsUseCase.exec({ + courseId, + studentId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const enrollmentMapper = makeEnrollmentMapper() + const { enrollment } = result.value + + const infraEnrollment = await enrollmentMapper.toPrisma(enrollment) + + return await reply.status(200).send({ + enrollment: EnrollmentPresenter.toHTTP(infraEnrollment) + }) +} diff --git a/src/infra/http/controllers/get-image-details.ts b/src/infra/http/controllers/get-image-details.ts new file mode 100644 index 0000000..a685208 --- /dev/null +++ b/src/infra/http/controllers/get-image-details.ts @@ -0,0 +1,37 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { ImageMapper } from '@/infra/database/prisma/mappers/image-mapper' +import { makeGetImageDetailsUseCase } from '@/infra/use-cases/factories/make-get-image-details-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { ImagePresenter } from '../presenters/image-presenter' + +const getImageDetailsParamsSchema = z.object({ + imageId: z.string().uuid() +}) + +export async function getImageDetailsController(request: FastifyRequest, reply: FastifyReply) { + const { imageId } = getImageDetailsParamsSchema.parse(request.params) + + const getImageInfoUseCase = makeGetImageDetailsUseCase() + + const result = await getImageInfoUseCase.exec({ + imageId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const image = ImageMapper.toPrisma(result.value.image) + + return await reply.status(200).send({ + image: ImagePresenter.toHTTP(image) + }) +} diff --git a/src/infra/http/controllers/get-student-progress.ts b/src/infra/http/controllers/get-student-progress.ts new file mode 100644 index 0000000..3478c82 --- /dev/null +++ b/src/infra/http/controllers/get-student-progress.ts @@ -0,0 +1,43 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeGetStudentProgressUseCase } from '@/infra/use-cases/factories/make-get-student-progress-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { ClassWithStudentProgressPresenter } from '../presenters/class-with-student-progress-presenter' +import { ModuleWithStudentProgressPresenter } from '../presenters/module-with-student-progress-presenter' + +const getStudentProgressParamsSchema = z.object({ + enrollmentId: z.string().uuid() +}) + +export async function getStudentProgressController(request: FastifyRequest, reply: FastifyReply) { + const { enrollmentId } = getStudentProgressParamsSchema.parse(request.params) + const { sub: studentId } = request.user + + const getStudentProgressUseCase = makeGetStudentProgressUseCase() + + const result = await getStudentProgressUseCase.exec({ + enrollmentId, + studentId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const { modules, classes } = result.value + + return await reply.status(200).send({ + classes: classes.map(classWithProgress => ClassWithStudentProgressPresenter.toHTTP(classWithProgress)), + modules: modules.map(moduleWithProgress => ModuleWithStudentProgressPresenter.toHTTP(moduleWithProgress)) + }) +} diff --git a/src/infra/http/controllers/get-user-details.ts b/src/infra/http/controllers/get-user-details.ts new file mode 100644 index 0000000..98486cf --- /dev/null +++ b/src/infra/http/controllers/get-user-details.ts @@ -0,0 +1,38 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeUserMapper } from '@/infra/database/prisma/mappers/factories/make-user-mapper' +import { makeGetUserInfoUseCase } from '@/infra/use-cases/factories/make-get-user-info-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { UserPresenter } from '../presenters/user-presenter' + +const getUserDetailsParamsSchema = z.object({ + userId: z.string().uuid() +}) + +export async function getUserDetailsController(request: FastifyRequest, reply: FastifyReply) { + const { userId } = getUserDetailsParamsSchema.parse(request.params) + + const getUserInfoUseCase = makeGetUserInfoUseCase() + + const result = await getUserInfoUseCase.exec({ + id: userId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const userMapper = makeUserMapper() + const user = await userMapper.toPrisma(result.value.user) + + return await reply.status(200).send({ + user: UserPresenter.toHTTP(user) + }) +} diff --git a/src/infra/http/controllers/get-video-details.ts b/src/infra/http/controllers/get-video-details.ts new file mode 100644 index 0000000..62f93bd --- /dev/null +++ b/src/infra/http/controllers/get-video-details.ts @@ -0,0 +1,37 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { VideoMapper } from '@/infra/database/prisma/mappers/video-mapper' +import { makeGetVideoDetailsUseCase } from '@/infra/use-cases/factories/make-get-video-details-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { VideoPresenter } from '../presenters/video-presenter' + +const getVideoDetailsParamsSchema = z.object({ + videoId: z.string().uuid() +}) + +export async function getVideoDetailsController(request: FastifyRequest, reply: FastifyReply) { + const { videoId } = getVideoDetailsParamsSchema.parse(request.params) + + const getVideoInfoUseCase = makeGetVideoDetailsUseCase() + + const result = await getVideoInfoUseCase.exec({ + videoId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const video = VideoMapper.toPrisma(result.value.video) + + return await reply.status(200).send({ + video: VideoPresenter.toHTTP(video) + }) +} diff --git a/src/infra/http/controllers/issue-certificate.ts b/src/infra/http/controllers/issue-certificate.ts new file mode 100644 index 0000000..231d5d6 --- /dev/null +++ b/src/infra/http/controllers/issue-certificate.ts @@ -0,0 +1,45 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { CertificateHasAlreadyBeenIssued } from '@/domain/course-management/application/use-cases/errors/certificate-has-already-been-issued-error' +import { CompleteTheCourseBeforeTheCertificateIIsIssuedError } from '@/domain/course-management/application/use-cases/errors/complete-the-course-before-the-certificate-is-issued-error' +import { StudentCertificateMapper } from '@/infra/database/prisma/mappers/student-certificate-mapper' +import { makeIssueCertificateUseCase } from '@/infra/use-cases/factories/make-issue-certificate-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { StudentCertificatePresenter } from '../presenters/student-certificate-presenter' + +const issueCertificateParamsSchema = z.object({ + enrollmentId: z.string().uuid() +}) + +export async function issueCertificateController(request: FastifyRequest, reply: FastifyReply) { + const { enrollmentId } = issueCertificateParamsSchema.parse(request.params) + const { sub: studentId } = request.user + + const issueCertificateUseCase = makeIssueCertificateUseCase() + + const result = await issueCertificateUseCase.exec({ + enrollmentId, + studentId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case CertificateHasAlreadyBeenIssued: + return await reply.status(409).send({ message: error.message }) + case CompleteTheCourseBeforeTheCertificateIIsIssuedError: + return await reply.status(403).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const studentCertificate = StudentCertificateMapper.toPrisma(result.value.issuedCertificate) + + return await reply.status(200).send({ + studentCertificate: StudentCertificatePresenter.toHTTP(studentCertificate) + }) +} diff --git a/src/infra/http/controllers/mark-course-as-completed.ts b/src/infra/http/controllers/mark-course-as-completed.ts new file mode 100644 index 0000000..2008392 --- /dev/null +++ b/src/infra/http/controllers/mark-course-as-completed.ts @@ -0,0 +1,42 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { AllModulesInTheCourseMustBeMarkedAsCompleted } from '@/domain/course-management/application/use-cases/errors/all-modules-in-the-course-must-be-marked-as-completed' +import { ItemAlreadyCompletedError } from '@/domain/course-management/application/use-cases/errors/item-already-completed-error' +import { makeMarkCourseAsCompletedUseCase } from '@/infra/use-cases/factories/make-mark-course-as-completed-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const markCourseAsCompletedParamsSchema = z.object({ + enrollmentId: z.string().uuid() +}) + +export async function markCourseAsCompletedController(request: FastifyRequest, reply: FastifyReply) { + const { enrollmentId } = markCourseAsCompletedParamsSchema.parse(request.params) + const { sub: studentId } = request.user + + const markCourseAsCompletedUseCase = makeMarkCourseAsCompletedUseCase() + + const result = await markCourseAsCompletedUseCase.exec({ + enrollmentId, + studentId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + case AllModulesInTheCourseMustBeMarkedAsCompleted: + return await reply.status(403).send({ message: error.message }) + case ItemAlreadyCompletedError: + return await reply.status(409).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(201).send() +} diff --git a/src/infra/http/controllers/query-courses-by-name.ts b/src/infra/http/controllers/query-courses-by-name.ts new file mode 100644 index 0000000..bb18fa6 --- /dev/null +++ b/src/infra/http/controllers/query-courses-by-name.ts @@ -0,0 +1,86 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type CourseWithInstructorAndEvaluationDTO } from '@/domain/course-management/enterprise/entities/dtos/course-with-instructor-and-evaluation' +import { CourseDtoMapper } from '@/domain/course-management/enterprise/entities/dtos/mappers/course-dto-mapper' +import { makeGetCourseEvaluationsAverageUseCase } from '@/infra/use-cases/factories/make-get-course-evaluations-average-use-case' +import { makeGetUserInfoUseCase } from '@/infra/use-cases/factories/make-get-user-info-use-case' +import { makeQueryCoursesByNameUseCase } from '@/infra/use-cases/factories/make-query-courses-by-name-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { CoursesWithInstructorAndEvaluationPresenter } from '../presenters/courses-with-instructor-and-evaluation-presenter' + +const queryCoursesByNameQuerySchema = z.object({ + q: z.string() +}) + +export async function queryCoursesByNameController(request: FastifyRequest, reply: FastifyReply) { + const { q } = queryCoursesByNameQuerySchema.parse(request.query) + + const queryCoursesByNameUseCase = makeQueryCoursesByNameUseCase() + const getUserInfoUseCase = makeGetUserInfoUseCase() + const getCourseEvaluationsAverageUseCase = makeGetCourseEvaluationsAverageUseCase() + + const result = await queryCoursesByNameUseCase.exec({ + query: q + }) + + if (result.isLeft()) { + return await reply.status(500).send() + } + + const { courses } = result.value + + const coursesWithInstructorAndEvaluation: CourseWithInstructorAndEvaluationDTO[] = [] + + await Promise.all( + courses.map(async (course) => { + const instructorResult = await getUserInfoUseCase.exec({ + id: course.instructorId.toString() + }) + const courseEvaluationAverageResult = await getCourseEvaluationsAverageUseCase.exec({ + courseId: course.id.toString() + }) + + if (instructorResult.isLeft()) { + const error = instructorResult.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + if (courseEvaluationAverageResult.isLeft()) { + return await reply.status(500).send() + } + + const { user } = instructorResult.value + const { evaluationsAverage } = courseEvaluationAverageResult.value + + const courseWithInstructorAndEvaluation: CourseWithInstructorAndEvaluationDTO = { + course: CourseDtoMapper.toDTO(course), + instructor: { + id: user.id, + name: user.name, + email: user.email, + age: user.age, + registeredAt: user.registeredAt, + summary: user.summary, + bannerImageKey: user.bannerImageKey, + profileImageKey: user.profileImageKey + }, + evaluationsAverage + } + + coursesWithInstructorAndEvaluation.push(courseWithInstructorAndEvaluation) + }) + ) + + return await reply.status(200).send({ + courses: coursesWithInstructorAndEvaluation.map(courseWithInstructorAndEvaluation => + CoursesWithInstructorAndEvaluationPresenter.toHTTP(courseWithInstructorAndEvaluation + ) + ) + }) +} diff --git a/src/infra/http/controllers/query-courses-by-tags.ts b/src/infra/http/controllers/query-courses-by-tags.ts new file mode 100644 index 0000000..48b8c91 --- /dev/null +++ b/src/infra/http/controllers/query-courses-by-tags.ts @@ -0,0 +1,86 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { type CourseWithInstructorAndEvaluationDTO } from '@/domain/course-management/enterprise/entities/dtos/course-with-instructor-and-evaluation' +import { CourseDtoMapper } from '@/domain/course-management/enterprise/entities/dtos/mappers/course-dto-mapper' +import { makeGetCourseEvaluationsAverageUseCase } from '@/infra/use-cases/factories/make-get-course-evaluations-average-use-case' +import { makeGetUserInfoUseCase } from '@/infra/use-cases/factories/make-get-user-info-use-case' +import { makeQueryCoursesByTagsUseCase } from '@/infra/use-cases/factories/make-query-courses-by-tags-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' +import { CoursesWithInstructorAndEvaluationPresenter } from '../presenters/courses-with-instructor-and-evaluation-presenter' + +const queryCoursesByTagQuerySchema = z.object({ + q: z.string() +}) + +export async function queryCoursesByTagController(request: FastifyRequest, reply: FastifyReply) { + const { q } = queryCoursesByTagQuerySchema.parse(request.query) + + const queryCoursesByTagsUseCase = makeQueryCoursesByTagsUseCase() + const getUserInfoUseCase = makeGetUserInfoUseCase() + const getCourseEvaluationsAverageUseCase = makeGetCourseEvaluationsAverageUseCase() + + const result = await queryCoursesByTagsUseCase.exec({ + query: q + }) + + if (result.isLeft()) { + return await reply.status(500).send() + } + + const { courses } = result.value + + const coursesWithInstructorAndEvaluation: CourseWithInstructorAndEvaluationDTO[] = [] + + await Promise.all( + courses.map(async (course) => { + const instructorResult = await getUserInfoUseCase.exec({ + id: course.instructorId.toString() + }) + const courseEvaluationAverageResult = await getCourseEvaluationsAverageUseCase.exec({ + courseId: course.id.toString() + }) + + if (instructorResult.isLeft()) { + const error = instructorResult.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + if (courseEvaluationAverageResult.isLeft()) { + return await reply.status(500).send() + } + + const { user } = instructorResult.value + const { evaluationsAverage } = courseEvaluationAverageResult.value + + const courseWithInstructorAndEvaluation: CourseWithInstructorAndEvaluationDTO = { + course: CourseDtoMapper.toDTO(course), + instructor: { + id: user.id, + name: user.name, + email: user.email, + age: user.age, + registeredAt: user.registeredAt, + summary: user.summary, + bannerImageKey: user.bannerImageKey, + profileImageKey: user.profileImageKey + }, + evaluationsAverage + } + + coursesWithInstructorAndEvaluation.push(courseWithInstructorAndEvaluation) + }) + ) + + return await reply.status(200).send({ + courses: coursesWithInstructorAndEvaluation.map(courseWithInstructorAndEvaluation => + CoursesWithInstructorAndEvaluationPresenter.toHTTP(courseWithInstructorAndEvaluation + ) + ) + }) +} diff --git a/src/infra/http/controllers/register-certificate-for-course.ts b/src/infra/http/controllers/register-certificate-for-course.ts new file mode 100644 index 0000000..1599fe5 --- /dev/null +++ b/src/infra/http/controllers/register-certificate-for-course.ts @@ -0,0 +1,41 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { CourseAlreadyHasACertificateError } from '@/domain/course-management/application/use-cases/errors/course-already-has-a-certificate-error' +import { makeRegisterCertificateForCourseUseCase } from '@/infra/use-cases/factories/make-register-certificate-for-course-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const registerCertificateForCourseParamsSchema = z.object({ + courseId: z.string().uuid(), + imageId: z.string().uuid() +}) + +export async function registerCertificateForCourseController(request: FastifyRequest, reply: FastifyReply) { + const { courseId, imageId } = registerCertificateForCourseParamsSchema.parse(request.params) + const { sub: instructorId } = request.user + + const getVideoInfoUseCase = makeRegisterCertificateForCourseUseCase() + + const result = await getVideoInfoUseCase.exec({ + courseId, + imageId, + instructorId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + case CourseAlreadyHasACertificateError: + return await reply.status(409).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(201).send() +} diff --git a/src/infra/http/controllers/register-course.ts b/src/infra/http/controllers/register-course.ts new file mode 100644 index 0000000..7d2cbad --- /dev/null +++ b/src/infra/http/controllers/register-course.ts @@ -0,0 +1,37 @@ +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { CourseAlreadyExistsInThisAccountError } from '@/domain/course-management/application/use-cases/errors/course-already-exists-in-this-account-error' +import { makeRegisterCourseUseCase } from '@/infra/use-cases/factories/make-register-course-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const registerCourseBodySchema = z.object({ + name: z.string(), + description: z.string() +}) + +export async function registerCourseController(request: FastifyRequest, reply: FastifyReply) { + const { name, description } = registerCourseBodySchema.parse(request.body) + + const registerCourseUseCase = makeRegisterCourseUseCase() + + const result = await registerCourseUseCase.exec({ + name, + description, + instructorId: request.user.sub + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case CourseAlreadyExistsInThisAccountError: + return await reply.status(409).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(201).send() +} diff --git a/src/infra/http/controllers/register-module-to-course.ts b/src/infra/http/controllers/register-module-to-course.ts new file mode 100644 index 0000000..9a994dc --- /dev/null +++ b/src/infra/http/controllers/register-module-to-course.ts @@ -0,0 +1,52 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { ModuleAlreadyExistsInThisCourseError } from '@/domain/course-management/application/use-cases/errors/module-already-exists-in-this-course-error' +import { ModuleNumberIsAlreadyInUseError } from '@/domain/course-management/application/use-cases/errors/module-number-already-in-use-error' +import { makeRegisterModuleToCourseUseCase } from '@/infra/use-cases/factories/make-register-module-to-course-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const registerModuleToCourseBodySchema = z.object({ + name: z.string(), + description: z.string(), + moduleNumber: z.number() +}) + +const registerModuleToCourseParamsSchema = z.object({ + courseId: z.string().uuid() +}) + +export async function registerModuleToCourseController(request: FastifyRequest, reply: FastifyReply) { + const { name, description, moduleNumber } = registerModuleToCourseBodySchema.parse(request.body) + const { courseId } = registerModuleToCourseParamsSchema.parse(request.params) + const { sub: instructorId } = request.user + + const registerModuleToCourseUseCase = makeRegisterModuleToCourseUseCase() + + const result = await registerModuleToCourseUseCase.exec({ + name, + description, + instructorId, + moduleNumber, + courseId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case ModuleAlreadyExistsInThisCourseError: + return await reply.status(409).send({ message: error.message }) + case ModuleNumberIsAlreadyInUseError: + return await reply.status(409).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(201).send() +} diff --git a/src/infra/http/controllers/register-tags.ts b/src/infra/http/controllers/register-tags.ts new file mode 100644 index 0000000..eed6851 --- /dev/null +++ b/src/infra/http/controllers/register-tags.ts @@ -0,0 +1,34 @@ +import { RepeatedTagError } from '@/domain/course-management/application/use-cases/errors/repeated-tag-error' +import { TagAlreadyExistsError } from '@/domain/course-management/application/use-cases/errors/tag-already-exists-error' +import { makeRegisterTagUseCase } from '@/infra/use-cases/factories/make-register-tag-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const registerTagsBodySchema = z.object({ + tags: z.array(z.string()) +}) + +export async function registerTagsController(request: FastifyRequest, reply: FastifyReply) { + const { tags } = registerTagsBodySchema.parse(request.body) + + const registerTagUseCase = makeRegisterTagUseCase() + + const result = await registerTagUseCase.exec({ + tags + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case RepeatedTagError: + return await reply.status(409).send({ message: error.message }) + case TagAlreadyExistsError: + return await reply.status(409).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(201).send() +} diff --git a/src/infra/http/controllers/register-user.ts b/src/infra/http/controllers/register-user.ts new file mode 100644 index 0000000..c5f7ef6 --- /dev/null +++ b/src/infra/http/controllers/register-user.ts @@ -0,0 +1,72 @@ +import { InstructorAlreadyExistsError } from '@/domain/course-management/application/use-cases/errors/instructor-already-exists-error' +import { StudentAlreadyExistsError } from '@/domain/course-management/application/use-cases/errors/student-already-exists-error' +import { makeRegisterInstructorUseCase } from '@/infra/use-cases/factories/make-register-instructor-use-case' +import { makeRegisterStudentUseCase } from '@/infra/use-cases/factories/make-register-student-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const registerUserBodySchema = z.object({ + name: z.string(), + email: z.string().email(), + password: z.string(), + age: z.number(), + cpf: z.string(), + role: z.enum(['STUDENT', 'INSTRUCTOR']), + summary: z.string() +}) + +export async function registerUserController(request: FastifyRequest, reply: FastifyReply) { + const { name, email, password, age, cpf, role, summary } = registerUserBodySchema.parse(request.body) + + if (role === 'STUDENT') { + const registerStudentUseCase = makeRegisterStudentUseCase() + + const result = await registerStudentUseCase.exec({ + name, + email, + password, + age, + cpf, + summary + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case StudentAlreadyExistsError: + return await reply.status(409).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(201).send() + } + + if (role === 'INSTRUCTOR') { + const registerInstructorUseCase = makeRegisterInstructorUseCase() + + const result = await registerInstructorUseCase.exec({ + name, + email, + password, + age, + cpf, + summary + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case InstructorAlreadyExistsError: + return await reply.status(409).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(201).send() + } +} diff --git a/src/infra/http/controllers/remove-tag-from-course.ts b/src/infra/http/controllers/remove-tag-from-course.ts new file mode 100644 index 0000000..b3317f2 --- /dev/null +++ b/src/infra/http/controllers/remove-tag-from-course.ts @@ -0,0 +1,38 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeRemoveTagFromCourseUseCase } from '@/infra/use-cases/factories/make-remove-tag-from-course-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const removeTagFromCourseParamsSchema = z.object({ + courseId: z.string().uuid(), + tagId: z.string().uuid() +}) + +export async function removeTagFromCourseController(request: FastifyRequest, reply: FastifyReply) { + const { courseId, tagId } = removeTagFromCourseParamsSchema.parse(request.params) + const { sub: instructorId } = request.user + + const RemoveTagFromCourseUseCase = makeRemoveTagFromCourseUseCase() + + const result = await RemoveTagFromCourseUseCase.exec({ + tagId, + courseId, + instructorId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(204).send() +} diff --git a/src/infra/http/controllers/toggle-mark-class-as-completed.ts b/src/infra/http/controllers/toggle-mark-class-as-completed.ts new file mode 100644 index 0000000..78f3698 --- /dev/null +++ b/src/infra/http/controllers/toggle-mark-class-as-completed.ts @@ -0,0 +1,38 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { makeToggleMarkClassAsCompletedUseCase } from '@/infra/use-cases/factories/make-toggle-mark-class-as-completed-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const toggleMarkClassAsCompletedParamsSchema = z.object({ + enrollmentId: z.string().uuid(), + classId: z.string().uuid() +}) + +export async function toggleMarkClassAsCompletedController(request: FastifyRequest, reply: FastifyReply) { + const { enrollmentId, classId } = toggleMarkClassAsCompletedParamsSchema.parse(request.params) + const { sub: studentId } = request.user + + const toggleMarkClassAsCompletedUseCase = makeToggleMarkClassAsCompletedUseCase() + + const result = await toggleMarkClassAsCompletedUseCase.exec({ + enrollmentId, + classId, + studentId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(204).send() +} diff --git a/src/infra/http/controllers/toggle-mark-module-as-completed.ts b/src/infra/http/controllers/toggle-mark-module-as-completed.ts new file mode 100644 index 0000000..97df0bb --- /dev/null +++ b/src/infra/http/controllers/toggle-mark-module-as-completed.ts @@ -0,0 +1,41 @@ +import { NotAllowedError } from '@/core/errors/errors/not-allowed-error' +import { ResourceNotFoundError } from '@/core/errors/errors/resource-not-found-error' +import { AllClassesInTheModuleMustBeMarkedAsCompleted } from '@/domain/course-management/application/use-cases/errors/all-classes-in-the-module-must-be-marked-as-completed' +import { makeToggleMarkModuleAsCompletedUseCase } from '@/infra/use-cases/factories/make-toggle-mark-module-as-completed-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { z } from 'zod' + +const toggleMarkModuleAsCompletedParamsSchema = z.object({ + enrollmentId: z.string().uuid(), + moduleId: z.string().uuid() +}) + +export async function toggleMarkModuleAsCompletedController(request: FastifyRequest, reply: FastifyReply) { + const { enrollmentId, moduleId } = toggleMarkModuleAsCompletedParamsSchema.parse(request.params) + const { sub: studentId } = request.user + + const toggleMarkModuleAsCompletedUseCase = makeToggleMarkModuleAsCompletedUseCase() + + const result = await toggleMarkModuleAsCompletedUseCase.exec({ + enrollmentId, + moduleId, + studentId + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case ResourceNotFoundError: + return await reply.status(404).send({ message: error.message }) + case NotAllowedError: + return await reply.status(401).send({ message: error.message }) + case AllClassesInTheModuleMustBeMarkedAsCompleted: + return await reply.status(403).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + return await reply.status(204).send() +} diff --git a/src/infra/http/controllers/upload-file.ts b/src/infra/http/controllers/upload-file.ts new file mode 100644 index 0000000..7477283 --- /dev/null +++ b/src/infra/http/controllers/upload-file.ts @@ -0,0 +1,49 @@ +import { InvalidMimeTypeError } from '@/core/errors/errors/invalid-mime-type-error' +import { makeFileMapper } from '@/infra/database/prisma/mappers/factories/make-file-mapper' +import { makeUploadFileUseCase } from '@/infra/use-cases/factories/make-upload-file-use-case' +import { type FastifyReply, type FastifyRequest } from 'fastify' +import { FilePresenter } from '../presenters/file-presenter' + +interface MulterRequest extends FastifyRequest { + file?: { + buffer: Buffer + originalname: string + mimetype: string + size: number + } +} + +export async function uploadFileController(request: MulterRequest, reply: FastifyReply) { + if (!request.file) { + return await reply.status(404).send({ + message: 'File required' + }) + } + + const uploadFileUseCase = makeUploadFileUseCase() + + const result = await uploadFileUseCase.exec({ + body: request.file.buffer, + fileName: request.file.originalname, + fileType: request.file.mimetype, + size: request.file.size + }) + + if (result.isLeft()) { + const error = result.value + + switch (error.constructor) { + case InvalidMimeTypeError: + return await reply.status(415).send({ message: error.message }) + default: + return await reply.status(500).send({ message: error.message }) + } + } + + const fileMapper = makeFileMapper() + const file = await fileMapper.toPrisma(result.value.file) + + return await reply.status(201).send({ + file: FilePresenter.toHTTP(file) + }) +} diff --git a/src/infra/http/middlewares/verify-jwt.ts b/src/infra/http/middlewares/verify-jwt.ts new file mode 100644 index 0000000..3e3183d --- /dev/null +++ b/src/infra/http/middlewares/verify-jwt.ts @@ -0,0 +1,9 @@ +import { type FastifyReply, type FastifyRequest } from 'fastify' + +export async function verifyJwt(request: FastifyRequest, reply: FastifyReply) { + try { + await request.jwtVerify() + } catch (err) { + return await reply.status(401).send({ message: 'Unauthorized' }) + } +} diff --git a/src/infra/http/middlewares/verify-user-role.ts b/src/infra/http/middlewares/verify-user-role.ts new file mode 100644 index 0000000..a07e0e5 --- /dev/null +++ b/src/infra/http/middlewares/verify-user-role.ts @@ -0,0 +1,11 @@ +import { type FastifyReply, type FastifyRequest } from 'fastify' + +export function verifyUserRole(roleToVerify: 'STUDENT' | 'INSTRUCTOR') { + return async (request: FastifyRequest, reply: FastifyReply) => { + const { role } = request.user + + if (role !== roleToVerify) { + return await reply.status(401).send({ message: 'Unauthorized' }) + } + } +} diff --git a/src/infra/http/presenters/class-presenter.ts b/src/infra/http/presenters/class-presenter.ts new file mode 100644 index 0000000..8e0c3bb --- /dev/null +++ b/src/infra/http/presenters/class-presenter.ts @@ -0,0 +1,14 @@ +import { type Prisma } from '@prisma/client' + +export class ClassPresenter { + static toHTTP(classToPresent: Prisma.ClassUncheckedCreateInput) { + return { + id: classToPresent.id, + name: classToPresent.name, + description: classToPresent.description, + classNumber: classToPresent.classNumber, + moduleId: classToPresent.moduleId, + videoId: classToPresent.videoId + } + } +} 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/course-presenter.ts b/src/infra/http/presenters/course-presenter.ts new file mode 100644 index 0000000..91de1f0 --- /dev/null +++ b/src/infra/http/presenters/course-presenter.ts @@ -0,0 +1,15 @@ +import { type Prisma } from '@prisma/client' + +export class CoursePresenter { + static toHTTP(course: Prisma.CourseUncheckedCreateInput) { + return { + id: course.id, + name: course.name, + description: course.description, + coverImageKey: course.coverImageKey, + bannerImageKey: course.bannerImageKey, + createdAt: course.createdAt, + instructorId: course.instructorId + } + } +} diff --git a/src/infra/http/presenters/courses-with-instructor-and-evaluation-presenter.ts b/src/infra/http/presenters/courses-with-instructor-and-evaluation-presenter.ts new file mode 100644 index 0000000..6f555cc --- /dev/null +++ b/src/infra/http/presenters/courses-with-instructor-and-evaluation-presenter.ts @@ -0,0 +1,27 @@ +import { type CourseWithInstructorAndEvaluationDTO } from '@/domain/course-management/enterprise/entities/dtos/course-with-instructor-and-evaluation' + +export class CoursesWithInstructorAndEvaluationPresenter { + static toHTTP(courseWithInstructorAndEvaluation: CourseWithInstructorAndEvaluationDTO) { + return { + course: { + id: courseWithInstructorAndEvaluation.course.id.toString(), + name: courseWithInstructorAndEvaluation.course.name, + description: courseWithInstructorAndEvaluation.course.description, + coverImageKey: courseWithInstructorAndEvaluation.course.coverImageKey, + bannerImageKey: courseWithInstructorAndEvaluation.course.bannerImageKey, + createdAt: courseWithInstructorAndEvaluation.course.createdAt + }, + instructor: { + id: courseWithInstructorAndEvaluation.instructor.id.toString(), + name: courseWithInstructorAndEvaluation.instructor.name, + email: courseWithInstructorAndEvaluation.instructor.email, + age: courseWithInstructorAndEvaluation.instructor.age, + summary: courseWithInstructorAndEvaluation.instructor.summary, + profileImageKey: courseWithInstructorAndEvaluation.instructor.profileImageKey, + bannerImageKey: courseWithInstructorAndEvaluation.instructor.bannerImageKey, + registeredAt: courseWithInstructorAndEvaluation.instructor.registeredAt + }, + evaluationsAverage: courseWithInstructorAndEvaluation.evaluationsAverage + } + } +} diff --git a/src/infra/http/presenters/enrollment-completed-item-presenter.ts b/src/infra/http/presenters/enrollment-completed-item-presenter.ts new file mode 100644 index 0000000..a0f7bcc --- /dev/null +++ b/src/infra/http/presenters/enrollment-completed-item-presenter.ts @@ -0,0 +1,12 @@ +import { type Prisma } from '@prisma/client' + +export class EnrollmentCompletedItemPresenter { + static toHTTP(enrollmentCompletedItem: Prisma.EnrollmentCompletedItemUncheckedCreateInput) { + return { + id: enrollmentCompletedItem.id, + enrollmentId: enrollmentCompletedItem.enrollmentId, + itemId: enrollmentCompletedItem.itemId, + itemType: enrollmentCompletedItem.itemType + } + } +} diff --git a/src/infra/http/presenters/enrollment-presenter.ts b/src/infra/http/presenters/enrollment-presenter.ts new file mode 100644 index 0000000..3ac2a91 --- /dev/null +++ b/src/infra/http/presenters/enrollment-presenter.ts @@ -0,0 +1,13 @@ +import { type Prisma } from '@prisma/client' + +export class EnrollmentPresenter { + static toHTTP(enrollment: Prisma.EnrollmentUncheckedCreateInput) { + return { + id: enrollment.id, + ocurredAt: enrollment.ocurredAt, + completedAt: enrollment.completedAt, + courseId: enrollment.courseId, + studentId: enrollment.studentId + } + } +} diff --git a/src/infra/http/presenters/evaluation-presenter.ts b/src/infra/http/presenters/evaluation-presenter.ts new file mode 100644 index 0000000..21a5b76 --- /dev/null +++ b/src/infra/http/presenters/evaluation-presenter.ts @@ -0,0 +1,13 @@ +import { type Prisma } from '@prisma/client' + +export class EvaluationPresenter { + static toHTTP(evaluation: Prisma.EvaluationUncheckedCreateInput) { + return { + id: evaluation.id, + value: evaluation.value, + createdAt: evaluation.createdAt, + studentId: evaluation.studentId, + classId: evaluation.classId + } + } +} diff --git a/src/infra/http/presenters/file-presenter.ts b/src/infra/http/presenters/file-presenter.ts new file mode 100644 index 0000000..6d25474 --- /dev/null +++ b/src/infra/http/presenters/file-presenter.ts @@ -0,0 +1,10 @@ +import { type Prisma } from '@prisma/client' + +export class FilePresenter { + static toHTTP(file: Prisma.FileUncheckedCreateInput) { + return { + id: file.id, + fileKey: file.key + } + } +} diff --git a/src/infra/http/presenters/image-presenter.ts b/src/infra/http/presenters/image-presenter.ts new file mode 100644 index 0000000..cd752e0 --- /dev/null +++ b/src/infra/http/presenters/image-presenter.ts @@ -0,0 +1,10 @@ +import { type Prisma } from '@prisma/client' + +export class ImagePresenter { + static toHTTP(image: Prisma.ImageUncheckedCreateInput) { + return { + id: image.id, + imageKey: image.fileKey + } + } +} diff --git a/src/infra/http/presenters/module-presenter.ts b/src/infra/http/presenters/module-presenter.ts new file mode 100644 index 0000000..88cb094 --- /dev/null +++ b/src/infra/http/presenters/module-presenter.ts @@ -0,0 +1,13 @@ +import { type Prisma } from '@prisma/client' + +export class ModulePresenter { + static toHTTP(module: Prisma.ModuleUncheckedCreateInput) { + return { + id: module.id, + name: module.name, + description: module.description, + moduleNumber: module.moduleNumber, + courseId: module.courseId + } + } +} 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/presenters/persenter.txt b/src/infra/http/presenters/persenter.txt deleted file mode 100644 index 6ec70fd..0000000 --- a/src/infra/http/presenters/persenter.txt +++ /dev/null @@ -1 +0,0 @@ -Presenter \ No newline at end of file diff --git a/src/infra/http/presenters/student-certificate-presenter.ts b/src/infra/http/presenters/student-certificate-presenter.ts new file mode 100644 index 0000000..5c51f51 --- /dev/null +++ b/src/infra/http/presenters/student-certificate-presenter.ts @@ -0,0 +1,12 @@ +import { type Prisma } from '@prisma/client' + +export class StudentCertificatePresenter { + static toHTTP(studentCertificate: Prisma.StudentCertificateUncheckedCreateInput) { + return { + id: studentCertificate.id, + certificateId: studentCertificate.certificateId, + studentId: studentCertificate.studentId, + issuedAt: studentCertificate.issuedAt + } + } +} diff --git a/src/infra/http/presenters/tag-presenter.ts b/src/infra/http/presenters/tag-presenter.ts new file mode 100644 index 0000000..cb516b8 --- /dev/null +++ b/src/infra/http/presenters/tag-presenter.ts @@ -0,0 +1,11 @@ +import { type Prisma } from '@prisma/client' + +export class TagPresenter { + static toHTTP(tag: Prisma.TagUncheckedCreateInput) { + return { + id: tag.id, + value: tag.value, + addedAt: tag.addedAt + } + } +} diff --git a/src/infra/http/presenters/user-presenter.ts b/src/infra/http/presenters/user-presenter.ts new file mode 100644 index 0000000..cb60612 --- /dev/null +++ b/src/infra/http/presenters/user-presenter.ts @@ -0,0 +1,17 @@ +import { type Prisma } from '@prisma/client' + +export class UserPresenter { + static toHTTP(user: Prisma.UserUncheckedCreateInput) { + return { + id: user.id, + name: user.name, + email: user.email, + cpf: user.cpf, + age: user.age, + summary: user.summary, + profileImageKey: user.profileImageKey, + bannerImageKey: user.bannerImageKey, + registeredAt: user.registeredAt + } + } +} diff --git a/src/infra/http/presenters/video-presenter.ts b/src/infra/http/presenters/video-presenter.ts new file mode 100644 index 0000000..6bbb56d --- /dev/null +++ b/src/infra/http/presenters/video-presenter.ts @@ -0,0 +1,10 @@ +import { type Prisma } from '@prisma/client' + +export class VideoPresenter { + static toHTTP(video: Prisma.VideoUncheckedCreateInput) { + return { + id: video.id, + videoKey: video.fileKey + } + } +} diff --git a/src/infra/http/routes/certificate.ts b/src/infra/http/routes/certificate.ts new file mode 100644 index 0000000..37b9ee7 --- /dev/null +++ b/src/infra/http/routes/certificate.ts @@ -0,0 +1,11 @@ +import { type FastifyInstance } from 'fastify' +import { deleteCourseCertificateController } from '../controllers/delete-course-certificate' +import { registerCertificateForCourseController } from '../controllers/register-certificate-for-course' +import { verifyJwt } from '../middlewares/verify-jwt' +import { verifyUserRole } from '../middlewares/verify-user-role' + +export async function certificateRoutes(app: FastifyInstance) { + app.post('/courses/:courseId/certificates/images/:imageId', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, registerCertificateForCourseController) + + app.delete('/courses/:courseId/certificates/:certificateId', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, deleteCourseCertificateController) +} diff --git a/src/infra/http/routes/class.ts b/src/infra/http/routes/class.ts new file mode 100644 index 0000000..71a48aa --- /dev/null +++ b/src/infra/http/routes/class.ts @@ -0,0 +1,17 @@ +import { type FastifyInstance } from 'fastify' +import { addClassToModuleController } from '../controllers/add-class-to-module' +import { deleteClassController } from '../controllers/delete-class' +import { editClassDetailsController } from '../controllers/edit-class-details' +import { fetchCourseClassesController } from '../controllers/fetch-course-classes' +import { verifyJwt } from '../middlewares/verify-jwt' +import { verifyUserRole } from '../middlewares/verify-user-role' + +export async function classRoutes(app: FastifyInstance) { + app.get('/courses/:courseId/classes', { onRequest: [verifyJwt] }, fetchCourseClassesController) + + app.post('/modules/:moduleId/classes/video/:videoId', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, addClassToModuleController) + + app.put('/classes/:classId', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, editClassDetailsController) + + app.delete('/classes/:classId', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, deleteClassController) +} diff --git a/src/infra/http/routes/course-tag.ts b/src/infra/http/routes/course-tag.ts new file mode 100644 index 0000000..f8506f4 --- /dev/null +++ b/src/infra/http/routes/course-tag.ts @@ -0,0 +1,14 @@ +import { type FastifyInstance } from 'fastify' +import { attachTagsToCourseController } from '../controllers/attach-tags-to-course' +import { fetchCourseTagsController } from '../controllers/fetch-course-tags' +import { removeTagFromCourseController } from '../controllers/remove-tag-from-course' +import { verifyJwt } from '../middlewares/verify-jwt' +import { verifyUserRole } from '../middlewares/verify-user-role' + +export async function courseTagRoutes(app: FastifyInstance) { + app.get('/courses/:courseId/tags', fetchCourseTagsController) + + app.post('/courses/:courseId/tags', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, attachTagsToCourseController) + + app.delete('/courses/:courseId/tags/:tagId', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, removeTagFromCourseController) +} diff --git a/src/infra/http/routes/course.ts b/src/infra/http/routes/course.ts new file mode 100644 index 0000000..99d7a75 --- /dev/null +++ b/src/infra/http/routes/course.ts @@ -0,0 +1,32 @@ +import { type FastifyInstance } from 'fastify' +import { deleteCourseController } from '../controllers/delete-course' +import { editCourseDetailsController } from '../controllers/edit-course-details' +import { fetchCourseStudentsController } from '../controllers/fetch-course-students' +import { fetchRecentCoursesController } from '../controllers/fetch-recent-courses' +import { getCourseDetailsController } from '../controllers/get-course-details' +import { getCourseInstructorDetailsController } from '../controllers/get-course-instructor-details' +import { getCourseMetricsController } from '../controllers/get-course-metrics' +import { getCourseStatsController } from '../controllers/get-course-stats' +import { queryCoursesByNameController } from '../controllers/query-courses-by-name' +import { queryCoursesByTagController } from '../controllers/query-courses-by-tags' +import { registerCourseController } from '../controllers/register-course' +import { verifyJwt } from '../middlewares/verify-jwt' +import { verifyUserRole } from '../middlewares/verify-user-role' + +export async function courseRoutes(app: FastifyInstance) { + app.get('/courses', fetchRecentCoursesController) + app.get('/courses/:courseId', getCourseDetailsController) + app.get('/courses/filter/name', queryCoursesByNameController) + app.get('/courses/filter/tags', queryCoursesByTagController) + app.get('/courses/:courseId/stats', getCourseStatsController) + app.get('/courses/:courseId/students', fetchCourseStudentsController) + app.get('/courses/:courseId/instructors', getCourseInstructorDetailsController) + + app.get('/courses/:courseId/metrics', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, getCourseMetricsController) + + app.post('/courses', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, registerCourseController) + + app.put('/courses/:courseId', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, editCourseDetailsController) + + app.delete('/courses/:courseId', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, deleteCourseController) +} diff --git a/src/infra/http/routes/enrollment.ts b/src/infra/http/routes/enrollment.ts new file mode 100644 index 0000000..3b80893 --- /dev/null +++ b/src/infra/http/routes/enrollment.ts @@ -0,0 +1,26 @@ +import { type FastifyInstance } from 'fastify' +import { cancelEnrollmentController } from '../controllers/cancel-enrollment' +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' +import { verifyJwt } from '../middlewares/verify-jwt' +import { verifyUserRole } from '../middlewares/verify-user-role' + +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) + app.post('/enrollments/:enrollmentId/modules/:moduleId/completed', { onRequest: [verifyJwt, verifyUserRole('STUDENT')] }, toggleMarkModuleAsCompletedController) + app.post('/enrollments/:enrollmentId/completed', { onRequest: [verifyJwt, verifyUserRole('STUDENT')] }, markCourseAsCompletedController) + + app.delete('/enrollments/:enrollmentId', { onRequest: [verifyJwt, verifyUserRole('STUDENT')] }, cancelEnrollmentController) +} diff --git a/src/infra/http/routes/evaluation.ts b/src/infra/http/routes/evaluation.ts new file mode 100644 index 0000000..1393390 --- /dev/null +++ b/src/infra/http/routes/evaluation.ts @@ -0,0 +1,14 @@ +import { type FastifyInstance } from 'fastify' +import { editEvaluationDetailsController } from '../controllers/edit-evaluation-details' +import { evaluateClassController } from '../controllers/evaluate-class' +import { getCourseEvaluationsAverageController } from '../controllers/get-course-evaluations-average' +import { verifyJwt } from '../middlewares/verify-jwt' +import { verifyUserRole } from '../middlewares/verify-user-role' + +export async function evaluationRoutes(app: FastifyInstance) { + app.get('/courses/:courseId/evaluations/average', getCourseEvaluationsAverageController) + + app.post('/courses/:courseId/classes/:classId/evaluations', { onRequest: [verifyJwt, verifyUserRole('STUDENT')] }, evaluateClassController) + + app.put('/evaluations/:evaluationId', { onRequest: [verifyJwt, verifyUserRole('STUDENT')] }, editEvaluationDetailsController) +} diff --git a/src/infra/http/routes/file.ts b/src/infra/http/routes/file.ts new file mode 100644 index 0000000..4c82373 --- /dev/null +++ b/src/infra/http/routes/file.ts @@ -0,0 +1,11 @@ +import { upload } from '@/infra/app' +import { makeOnFileUploaded } from '@/infra/events/factories/make-on-file-uploaded' +import { type FastifyInstance } from 'fastify' +import { uploadFileController } from '../controllers/upload-file' +import { verifyJwt } from '../middlewares/verify-jwt' + +export async function fileRoutes(app: FastifyInstance) { + makeOnFileUploaded() + + app.post('/files', { onRequest: [verifyJwt], preHandler: [upload.single('file')] }, uploadFileController) +} diff --git a/src/infra/http/routes/image.ts b/src/infra/http/routes/image.ts new file mode 100644 index 0000000..5d50de8 --- /dev/null +++ b/src/infra/http/routes/image.ts @@ -0,0 +1,7 @@ +import { type FastifyInstance } from 'fastify' +import { getImageDetailsController } from '../controllers/get-image-details' +import { verifyJwt } from '../middlewares/verify-jwt' + +export async function imageRoutes(app: FastifyInstance) { + app.get('/images/:imageId', { onRequest: [verifyJwt] }, getImageDetailsController) +} diff --git a/src/infra/http/routes/instructor.ts b/src/infra/http/routes/instructor.ts new file mode 100644 index 0000000..4560a30 --- /dev/null +++ b/src/infra/http/routes/instructor.ts @@ -0,0 +1,6 @@ +import { type FastifyInstance } from 'fastify' +import { fetchInstructorCoursesController } from '../controllers/fetch-instructor-courses' + +export async function instructorRoutes(app: FastifyInstance) { + app.get('/instructors/:instructorId/courses', fetchInstructorCoursesController) +} diff --git a/src/infra/http/routes/module.ts b/src/infra/http/routes/module.ts new file mode 100644 index 0000000..07a1508 --- /dev/null +++ b/src/infra/http/routes/module.ts @@ -0,0 +1,19 @@ +import { type FastifyInstance } from 'fastify' +import { deleteModuleController } from '../controllers/delete-module' +import { editModuleDetailsController } from '../controllers/edit-module-details' +import { fetchCourseModulesController } from '../controllers/fetch-course-modules' +import { fetchModuleClassesController } from '../controllers/fetch-module-classes' +import { registerModuleToCourseController } from '../controllers/register-module-to-course' +import { verifyJwt } from '../middlewares/verify-jwt' +import { verifyUserRole } from '../middlewares/verify-user-role' + +export async function moduleRoutes(app: FastifyInstance) { + app.get('/courses/:courseId/modules', { onRequest: [verifyJwt] }, fetchCourseModulesController) + app.get('/modules/:moduleId/classes', { onRequest: [verifyJwt] }, fetchModuleClassesController) + + app.post('/courses/:courseId/modules', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, registerModuleToCourseController) + + app.put('/modules/:moduleId', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, editModuleDetailsController) + + app.delete('/modules/:moduleId', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, deleteModuleController) +} diff --git a/src/infra/http/routes/route.txt b/src/infra/http/routes/route.txt deleted file mode 100644 index ac9389b..0000000 --- a/src/infra/http/routes/route.txt +++ /dev/null @@ -1 +0,0 @@ -Route \ No newline at end of file diff --git a/src/infra/http/routes/student-certificate.ts b/src/infra/http/routes/student-certificate.ts new file mode 100644 index 0000000..ddf84ca --- /dev/null +++ b/src/infra/http/routes/student-certificate.ts @@ -0,0 +1,8 @@ +import { type FastifyInstance } from 'fastify' +import { issueCertificateController } from '../controllers/issue-certificate' +import { verifyJwt } from '../middlewares/verify-jwt' +import { verifyUserRole } from '../middlewares/verify-user-role' + +export async function studentCertificateRoutes(app: FastifyInstance) { + app.post('/enrollments/:enrollmentId/certificates/issue', { onRequest: [verifyJwt, verifyUserRole('STUDENT')] }, issueCertificateController) +} diff --git a/src/infra/http/routes/student.ts b/src/infra/http/routes/student.ts new file mode 100644 index 0000000..8c95031 --- /dev/null +++ b/src/infra/http/routes/student.ts @@ -0,0 +1,6 @@ +import { type FastifyInstance } from 'fastify' +import { fetchStudentCoursesController } from '../controllers/fetch-student-courses' + +export async function studentRoutes(app: FastifyInstance) { + app.get('/students/:studentId/enrollments', fetchStudentCoursesController) +} diff --git a/src/infra/http/routes/tag.ts b/src/infra/http/routes/tag.ts new file mode 100644 index 0000000..f692d29 --- /dev/null +++ b/src/infra/http/routes/tag.ts @@ -0,0 +1,11 @@ +import { type FastifyInstance } from 'fastify' +import { fetchRecentTagsController } from '../controllers/fetch-recent-tags' +import { registerTagsController } from '../controllers/register-tags' +import { verifyJwt } from '../middlewares/verify-jwt' +import { verifyUserRole } from '../middlewares/verify-user-role' + +export async function tagRoutes(app: FastifyInstance) { + app.get('/tags', fetchRecentTagsController) + + app.post('/tags', { onRequest: [verifyJwt, verifyUserRole('INSTRUCTOR')] }, registerTagsController) +} diff --git a/src/infra/http/routes/user.ts b/src/infra/http/routes/user.ts new file mode 100644 index 0000000..4f30652 --- /dev/null +++ b/src/infra/http/routes/user.ts @@ -0,0 +1,18 @@ +import { type FastifyInstance } from 'fastify' +import { authenticateUserController } from '../controllers/authenticate' +import { deleteUserController } from '../controllers/delete-user' +import { editUserDetailsController } from '../controllers/edit-user-details' +import { getUserDetailsController } from '../controllers/get-user-details' +import { registerUserController } from '../controllers/register-user' +import { verifyJwt } from '../middlewares/verify-jwt' + +export async function userRoutes(app: FastifyInstance) { + app.get('/users/:userId', getUserDetailsController) + + app.post('/users', registerUserController) + app.post('/sessions', authenticateUserController) + + app.put('/users', { onRequest: [verifyJwt] }, editUserDetailsController) + + app.delete('/users', { onRequest: [verifyJwt] }, deleteUserController) +} diff --git a/src/infra/http/routes/video.ts b/src/infra/http/routes/video.ts new file mode 100644 index 0000000..b3de62d --- /dev/null +++ b/src/infra/http/routes/video.ts @@ -0,0 +1,7 @@ +import { type FastifyInstance } from 'fastify' +import { getVideoDetailsController } from '../controllers/get-video-details' +import { verifyJwt } from '../middlewares/verify-jwt' + +export async function videoRoutes(app: FastifyInstance) { + app.get('/videos/:videoId', { onRequest: [verifyJwt] }, getVideoDetailsController) +} diff --git a/src/infra/storage/r2-storage.ts b/src/infra/storage/r2-storage.ts new file mode 100644 index 0000000..ddce5eb --- /dev/null +++ b/src/infra/storage/r2-storage.ts @@ -0,0 +1,47 @@ +import { + type UploadParams, + type Uploader +} from '@/domain/storage/application/upload/uploader' + +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3' +import { randomUUID } from 'node:crypto' +import { env } from '../env' + +export class R2Storage implements Uploader { + private readonly client: S3Client + + constructor() { + const accountId = env.CLOUDFLARE_ACCOUNT_ID + + this.client = new S3Client({ + endpoint: `https://${accountId}.r2.cloudflarestorage.com`, + region: 'auto', + credentials: { + accessKeyId: env.AWS_ACCESS_KEY_ID, + secretAccessKey: env.AWS_SECRET_ACCESS_KEY + } + }) + } + + async upload({ + fileName, + fileType, + body + }: UploadParams): Promise<{ key: string }> { + const uploadId = randomUUID() + const uniqueFileName = `${uploadId}-${fileName}` + + await this.client.send( + new PutObjectCommand({ + Bucket: env.AWS_BUCKET_NAME, + Key: uniqueFileName, + ContentType: fileType, + Body: body + }) + ) + + return { + key: uniqueFileName + } + } +} diff --git a/src/infra/storage/utils/get-video-duration.ts b/src/infra/storage/utils/get-video-duration.ts new file mode 100644 index 0000000..4fcd3fe --- /dev/null +++ b/src/infra/storage/utils/get-video-duration.ts @@ -0,0 +1,14 @@ +import toStream from 'buffer-to-stream' +import getVideoDurationInSeconds from 'get-video-duration' + +export class GetVideoDuration { + async getInSecondsByBuffer(videoBuffer: Buffer): Promise { + const videoStream = toStream(videoBuffer) + + const duration = await getVideoDurationInSeconds( + videoStream + ) + + return duration + } +} diff --git a/src/infra/use-cases/factories/make-authenticate-user-use-case.ts b/src/infra/use-cases/factories/make-authenticate-user-use-case.ts index 94ea22a..0200c78 100644 --- a/src/infra/use-cases/factories/make-authenticate-user-use-case.ts +++ b/src/infra/use-cases/factories/make-authenticate-user-use-case.ts @@ -1,12 +1,13 @@ import { AuthenticateUserUseCase } from '@/domain/course-management/application/use-cases/authenticate-user' import { BcryptHasher } from '@/infra/cryptography/bcrypt-hasher' -import { FakeEncrypter } from '@/infra/cryptography/jwt-encrypter' +import { JWTEncrypter } from '@/infra/cryptography/jwt-encrypter' import { makePrismaUsersRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-users-repository' +import { type FastifyReply } from 'fastify' -export function makeAuthenticateUserUseCase() { +export function makeAuthenticateUserUseCase(reply: FastifyReply) { const prismaUsersRepository = makePrismaUsersRepository() const bcryptHasher = new BcryptHasher() - const jwtEncrypter = new FakeEncrypter() + const jwtEncrypter = new JWTEncrypter(reply) const authenticateUserUseCase = new AuthenticateUserUseCase( prismaUsersRepository, diff --git a/src/infra/use-cases/factories/make-fetch-course-modules.ts b/src/infra/use-cases/factories/make-fetch-course-modules.ts new file mode 100644 index 0000000..4ede4ba --- /dev/null +++ b/src/infra/use-cases/factories/make-fetch-course-modules.ts @@ -0,0 +1,15 @@ +import { FetchCourseModulesUseCase } from '@/domain/course-management/application/use-cases/fetch-course-modules' +import { makePrismaCoursesRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-courses-repository' +import { makePrismaModulesRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-modules-repository' + +export function makeFetchCourseModulesUseCase() { + const prismaCoursesRepository = makePrismaCoursesRepository() + const prismaModulesRepository = makePrismaModulesRepository() + + const fetchCourseModulesUseCase = new FetchCourseModulesUseCase( + prismaCoursesRepository, + prismaModulesRepository + ) + + return fetchCourseModulesUseCase +} diff --git a/src/infra/use-cases/factories/make-fetch-enrollment-completed-classes-use-case.ts b/src/infra/use-cases/factories/make-fetch-enrollment-completed-classes-use-case.ts new file mode 100644 index 0000000..af928f0 --- /dev/null +++ b/src/infra/use-cases/factories/make-fetch-enrollment-completed-classes-use-case.ts @@ -0,0 +1,15 @@ +import { FetchEnrollmentCompletedClassesUseCase } from '@/domain/course-management/application/use-cases/fetch-enrollment-completed-classes' +import { makePrismaEnrollmentsRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-enrollments-repository' +import { PrismaEnrollmentCompleteItemsRepository } from '@/infra/database/prisma/repositories/prisma-enrollment-completed-items-repository' + +export function makeFetchEnrollmentCompletedClassesUseCase() { + const prismaEnrollmentsRepository = makePrismaEnrollmentsRepository() + const prismaEnrollmentCompletedItemsRepository = new PrismaEnrollmentCompleteItemsRepository() + + const fetchEnrollmentCompletedClassesUseCase = new FetchEnrollmentCompletedClassesUseCase( + prismaEnrollmentsRepository, + prismaEnrollmentCompletedItemsRepository + ) + + return fetchEnrollmentCompletedClassesUseCase +} diff --git a/src/infra/use-cases/factories/make-fetch-enrollment-completed-modules-use-case.ts b/src/infra/use-cases/factories/make-fetch-enrollment-completed-modules-use-case.ts new file mode 100644 index 0000000..6e774b8 --- /dev/null +++ b/src/infra/use-cases/factories/make-fetch-enrollment-completed-modules-use-case.ts @@ -0,0 +1,15 @@ +import { FetchEnrollmentCompletedModulesUseCase } from '@/domain/course-management/application/use-cases/fetch-enrollment-completed-modules' +import { makePrismaEnrollmentsRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-enrollments-repository' +import { PrismaEnrollmentCompleteItemsRepository } from '@/infra/database/prisma/repositories/prisma-enrollment-completed-items-repository' + +export function makeFetchEnrollmentCompletedModulesUseCase() { + const prismaEnrollmentsRepository = makePrismaEnrollmentsRepository() + const prismaEnrollmentCompletedItemsRepository = new PrismaEnrollmentCompleteItemsRepository() + + const fetchEnrollmentCompletedModulesUseCase = new FetchEnrollmentCompletedModulesUseCase( + prismaEnrollmentsRepository, + prismaEnrollmentCompletedItemsRepository + ) + + return fetchEnrollmentCompletedModulesUseCase +} diff --git a/src/infra/use-cases/factories/make-get-course-instructor-details-use-case.ts b/src/infra/use-cases/factories/make-get-course-instructor-details-use-case.ts new file mode 100644 index 0000000..40991d0 --- /dev/null +++ b/src/infra/use-cases/factories/make-get-course-instructor-details-use-case.ts @@ -0,0 +1,15 @@ +import { GetCourseInstructorDetailsUseCase } from '@/domain/course-management/application/use-cases/get-course-instructor-details' +import { makePrismaCoursesRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-courses-repository' +import { makePrismaInstructorsRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-instructors-repository' + +export function makeGetCourseInstructorDetailsUseCase() { + const prismaCoursesRepository = makePrismaCoursesRepository() + const prismaInstructorsRepository = makePrismaInstructorsRepository() + + const getCourseDetailsUseCase = new GetCourseInstructorDetailsUseCase( + prismaCoursesRepository, + prismaInstructorsRepository + ) + + return getCourseDetailsUseCase +} diff --git a/src/infra/use-cases/factories/make-get-image-details-use-case.ts b/src/infra/use-cases/factories/make-get-image-details-use-case.ts new file mode 100644 index 0000000..6bcbcf0 --- /dev/null +++ b/src/infra/use-cases/factories/make-get-image-details-use-case.ts @@ -0,0 +1,12 @@ +import { GetImageDetailsUseCase } from '@/domain/course-management/application/use-cases/get-image-details' +import { PrismaImagesRepository } from '@/infra/database/prisma/repositories/prisma-images-repository' + +export function makeGetImageDetailsUseCase() { + const prismaImagesRepository = new PrismaImagesRepository() + + const getImageDetailsUseCase = new GetImageDetailsUseCase( + prismaImagesRepository + ) + + return getImageDetailsUseCase +} 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 +} diff --git a/src/infra/use-cases/factories/make-get-video-details-use-case.ts b/src/infra/use-cases/factories/make-get-video-details-use-case.ts new file mode 100644 index 0000000..6e7148f --- /dev/null +++ b/src/infra/use-cases/factories/make-get-video-details-use-case.ts @@ -0,0 +1,12 @@ +import { GetVideoDetailsUseCase } from '@/domain/course-management/application/use-cases/get-video-details' +import { PrismaVideosRepository } from '@/infra/database/prisma/repositories/prisma-videos-repository' + +export function makeGetVideoDetailsUseCase() { + const prismaVideosRepository = new PrismaVideosRepository() + + const getVideoDetailsUseCase = new GetVideoDetailsUseCase( + prismaVideosRepository + ) + + return getVideoDetailsUseCase +} diff --git a/src/infra/use-cases/factories/make-register-certificate-for-course-use-case.ts b/src/infra/use-cases/factories/make-register-certificate-for-course-use-case.ts index 8c3db9a..e10b2fe 100644 --- a/src/infra/use-cases/factories/make-register-certificate-for-course-use-case.ts +++ b/src/infra/use-cases/factories/make-register-certificate-for-course-use-case.ts @@ -1,16 +1,16 @@ import { RegisterCertificateForCourseUseCase } from '@/domain/course-management/application/use-cases/register-certificate-for-course' import { makePrismaCertificatesRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-certificates-repository' import { makePrismaCoursesRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-courses-repository' -import { InMemoryImagesRepository } from '../../../../test/repositories/in-memory-images-repository' +import { PrismaImagesRepository } from '@/infra/database/prisma/repositories/prisma-images-repository' export function makeRegisterCertificateForCourseUseCase() { const prismaCertificatesRepository = makePrismaCertificatesRepository() - const inMemoryImagesRepository = new InMemoryImagesRepository() + const prismaImagesRepository = new PrismaImagesRepository() const prismaCoursesRepository = makePrismaCoursesRepository() const registerCertificateForCourseUseCase = new RegisterCertificateForCourseUseCase( prismaCertificatesRepository, - inMemoryImagesRepository, + prismaImagesRepository, prismaCoursesRepository ) diff --git a/src/infra/use-cases/factories/make-mark-class-as-completed-use-case.ts b/src/infra/use-cases/factories/make-toggle-mark-class-as-completed-use-case.ts similarity index 81% rename from src/infra/use-cases/factories/make-mark-class-as-completed-use-case.ts rename to src/infra/use-cases/factories/make-toggle-mark-class-as-completed-use-case.ts index 5393709..aba0813 100644 --- a/src/infra/use-cases/factories/make-mark-class-as-completed-use-case.ts +++ b/src/infra/use-cases/factories/make-toggle-mark-class-as-completed-use-case.ts @@ -1,4 +1,4 @@ -import { MarkClassAsCompletedUseCase } from '@/domain/course-management/application/use-cases/mark-class-as-completed' +import { ToggleMarkClassAsCompletedUseCase } from '@/domain/course-management/application/use-cases/toggle-mark-class-as-completed' import { makePrismaClassesRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-classes-repository' import { makePrismaCoursesRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-courses-repository' import { makePrismaEnrollmentsRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-enrollments-repository' @@ -6,7 +6,7 @@ import { makePrismaModulesRepository } from '@/infra/database/prisma/repositorie import { makePrismaStudentsRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-students-repository' import { PrismaEnrollmentCompleteItemsRepository } from '@/infra/database/prisma/repositories/prisma-enrollment-completed-items-repository' -export function makeMarkClassAsCompletedUseCase() { +export function makeToggleMarkClassAsCompletedUseCase() { const prismaEnrollmentsRepository = makePrismaEnrollmentsRepository() const prismaCoursesRepository = makePrismaCoursesRepository() const prismaClassesRepository = makePrismaClassesRepository() @@ -14,7 +14,7 @@ export function makeMarkClassAsCompletedUseCase() { const prismaStudentsRepository = makePrismaStudentsRepository() const prismaEnrollmentCompletedItemsRepository = new PrismaEnrollmentCompleteItemsRepository() - const markClassAsCompletedUseCase = new MarkClassAsCompletedUseCase( + const toggleMarkClassAsCompletedUseCase = new ToggleMarkClassAsCompletedUseCase( prismaEnrollmentsRepository, prismaCoursesRepository, prismaModulesRepository, @@ -23,5 +23,5 @@ export function makeMarkClassAsCompletedUseCase() { prismaEnrollmentCompletedItemsRepository ) - return markClassAsCompletedUseCase + return toggleMarkClassAsCompletedUseCase } diff --git a/src/infra/use-cases/factories/make-mark-module-as-completed-use-case.ts b/src/infra/use-cases/factories/make-toggle-mark-module-as-completed-use-case.ts similarity index 78% rename from src/infra/use-cases/factories/make-mark-module-as-completed-use-case.ts rename to src/infra/use-cases/factories/make-toggle-mark-module-as-completed-use-case.ts index 3b0ce3e..7cb0a46 100644 --- a/src/infra/use-cases/factories/make-mark-module-as-completed-use-case.ts +++ b/src/infra/use-cases/factories/make-toggle-mark-module-as-completed-use-case.ts @@ -1,18 +1,18 @@ -import { MarkModuleAsCompletedUseCase } from '@/domain/course-management/application/use-cases/mark-module-as-completed' +import { ToggleMarkModuleAsCompletedUseCase } from '@/domain/course-management/application/use-cases/toggle-mark-module-as-completed' import { makePrismaClassesRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-classes-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 { makePrismaStudentsRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-students-repository' import { PrismaEnrollmentCompleteItemsRepository } from '@/infra/database/prisma/repositories/prisma-enrollment-completed-items-repository' -export function makeMarkModuleAsCompletedUseCase() { +export function makeToggleMarkModuleAsCompletedUseCase() { const prismaEnrollmentsRepository = makePrismaEnrollmentsRepository() const prismaClassesRepository = makePrismaClassesRepository() const prismaModulesRepository = makePrismaModulesRepository() const prismaStudentsRepository = makePrismaStudentsRepository() const prismaEnrollmentCompletedItemsRepository = new PrismaEnrollmentCompleteItemsRepository() - const markModuleAsCompletedUseCase = new MarkModuleAsCompletedUseCase( + const toggleMarkModuleAsCompletedUseCase = new ToggleMarkModuleAsCompletedUseCase( prismaEnrollmentsRepository, prismaModulesRepository, prismaClassesRepository, @@ -20,5 +20,5 @@ export function makeMarkModuleAsCompletedUseCase() { prismaEnrollmentCompletedItemsRepository ) - return markModuleAsCompletedUseCase + return toggleMarkModuleAsCompletedUseCase } diff --git a/src/infra/use-cases/factories/make-upload-file-use-case.ts b/src/infra/use-cases/factories/make-upload-file-use-case.ts new file mode 100644 index 0000000..3acbed9 --- /dev/null +++ b/src/infra/use-cases/factories/make-upload-file-use-case.ts @@ -0,0 +1,15 @@ +import { UploadFileUseCase } from '@/domain/storage/application/use-cases/upload-file' +import { makePrismaFilesRepository } from '@/infra/database/prisma/repositories/factories/make-prisma-files-repository' +import { R2Storage } from '@/infra/storage/r2-storage' + +export function makeUploadFileUseCase() { + const prismaFilesRepository = makePrismaFilesRepository() + const R2Uploader = new R2Storage() + + const uploadFileUseCase = new UploadFileUseCase( + prismaFilesRepository, + R2Uploader + ) + + return uploadFileUseCase +} diff --git a/src/infra/use-cases/factories/make-upload-image-use-case.ts b/src/infra/use-cases/factories/make-upload-image-use-case.ts index a742a68..b9c50ff 100644 --- a/src/infra/use-cases/factories/make-upload-image-use-case.ts +++ b/src/infra/use-cases/factories/make-upload-image-use-case.ts @@ -1,11 +1,11 @@ import { UploadImageUseCase } from '@/domain/course-management/application/use-cases/upload-image' -import { InMemoryImagesRepository } from '../../../../test/repositories/in-memory-images-repository' +import { PrismaImagesRepository } from '@/infra/database/prisma/repositories/prisma-images-repository' export function makeUploadImageUseCase() { - const inMemoryImagesRepository = new InMemoryImagesRepository() + const prismaImagesRepository = new PrismaImagesRepository() const uploadImageUseCase = new UploadImageUseCase( - inMemoryImagesRepository + prismaImagesRepository ) return uploadImageUseCase diff --git a/test/cryptography/fake-encrypter.ts b/test/cryptography/fake-encrypter.ts index ed18070..d660841 100644 --- a/test/cryptography/fake-encrypter.ts +++ b/test/cryptography/fake-encrypter.ts @@ -1,7 +1,7 @@ -import { type Encrypter } from '@/domain/course-management/application/cryptography/encrypter' +import { type Encrypter, type EncrypterProps } from '@/domain/course-management/application/cryptography/encrypter' export class FakeEncrypter implements Encrypter { - async encrypt(payload: Record): Promise { + async encrypt(payload: EncrypterProps): Promise { return JSON.stringify(payload) } } diff --git a/test/factories/make-enrollment.ts b/test/factories/make-enrollment.ts index e45be2a..e07edaa 100644 --- a/test/factories/make-enrollment.ts +++ b/test/factories/make-enrollment.ts @@ -9,7 +9,6 @@ export function makeEnrollment( { studentId: override.courseId ?? new UniqueEntityID(), courseId: override.courseId ?? new UniqueEntityID(), - completedItems: [], ocurredAt: override.ocurredAt ?? new Date(), completedAt: override.completedAt ?? null, ...override diff --git a/test/factories/make-file.ts b/test/factories/make-file.ts new file mode 100644 index 0000000..3649a4c --- /dev/null +++ b/test/factories/make-file.ts @@ -0,0 +1,23 @@ +import { type UniqueEntityID } from '@/core/entities/unique-entity-id' +import { File, type FileProps } from '@/domain/storage/enterprise/entities/file' +import { faker } from '@faker-js/faker' + +export function makeFile( + override: Partial = {}, + id?: UniqueEntityID +) { + const file = File.create( + { + fileName: faker.company.name(), + fileType: 'file/mp4', + body: Buffer.from(faker.lorem.slug()), + size: faker.number.int(), + fileKey: override.fileKey ?? 'file-key', + storedAt: override.storedAt ?? new Date(), + ...override + }, + id + ) + + return file +} diff --git a/test/repositories/in-memory-enrollment-completed-items-repository.ts b/test/repositories/in-memory-enrollment-completed-items-repository.ts index d18beab..6abbf11 100644 --- a/test/repositories/in-memory-enrollment-completed-items-repository.ts +++ b/test/repositories/in-memory-enrollment-completed-items-repository.ts @@ -14,6 +14,24 @@ export class InMemoryEnrollmentCompletedItemsRepository implements EnrollmentCom return completedItem } + async findByEnrollmentIdAndItemId(enrollmentId: string, itemId: string): Promise { + // eslint-disable-next-line array-callback-return + const completedItem = this.items.find(completeCourseItemToFind => { + if ( + completeCourseItemToFind.enrollmentId.toString() === enrollmentId && + completeCourseItemToFind.itemId.toString() === itemId + ) { + return completeCourseItemToFind + } + }) + + if (!completedItem) { + return null + } + + return completedItem + } + async findManyCompletedClassesByEnrollmentId(enrollmentId: string): Promise { const completedClassesOnEnrollment: EnrollmentCompletedItem[] = [] @@ -46,4 +64,9 @@ export class InMemoryEnrollmentCompletedItemsRepository implements EnrollmentCom this.items.push(enrollmentCompletedItem) return enrollmentCompletedItem } + + async delete(completedItem: EnrollmentCompletedItem): Promise { + const completedItemIndex = this.items.indexOf(completedItem) + this.items.splice(completedItemIndex, 1) + } } diff --git a/test/repositories/in-memory-enrollments-repository.ts b/test/repositories/in-memory-enrollments-repository.ts index c875321..aaa7c4e 100644 --- a/test/repositories/in-memory-enrollments-repository.ts +++ b/test/repositories/in-memory-enrollments-repository.ts @@ -60,19 +60,6 @@ export class InMemoryEnrollmentsRepository implements EnrollmentsRepository { return courseStudents } - async markItemAsCompleted(itemId: string, enrollment: Enrollment): Promise { - const enrollmentCompletedItem = await this.inMemoryEnrollmentCompletedItemsRepository.findById(itemId) - - if (!enrollmentCompletedItem) { - return null - } - - enrollment.completedItems.push(enrollmentCompletedItem.id) - await this.save(enrollment) - - return enrollment - } - async markAsCompleted(enrollment: Enrollment): Promise { enrollment.completedAt = new Date() await this.save(enrollment) diff --git a/test/repositories/in-memory-files-repository.ts b/test/repositories/in-memory-files-repository.ts index bae03ab..7921484 100644 --- a/test/repositories/in-memory-files-repository.ts +++ b/test/repositories/in-memory-files-repository.ts @@ -1,3 +1,4 @@ +import { DomainEvents } from '@/core/events/domain-events' import { type FilesRepository } from '@/domain/storage/application/repositories/files-repository' import { type File } from '@/domain/storage/enterprise/entities/file' @@ -26,6 +27,9 @@ export class InMemoryFilesRepository implements FilesRepository { async create(file: File): Promise { this.items.push(file) + + DomainEvents.dispatchEventsForAggregate(file.id) + return file } } diff --git a/test/repositories/in-memory-images-repository.ts b/test/repositories/in-memory-images-repository.ts index a766802..5ebce0c 100644 --- a/test/repositories/in-memory-images-repository.ts +++ b/test/repositories/in-memory-images-repository.ts @@ -1,4 +1,3 @@ -import { DomainEvents } from '@/core/events/domain-events' import { type ImagesRepository } from '@/domain/course-management/application/repositories/images-repository' import { type Image } from '@/domain/course-management/enterprise/entities/image' @@ -34,6 +33,9 @@ export class InMemoryImagesRepository implements ImagesRepository { if (!imageToAppendKey.imageKey) { imageToAppendKey.imageKey = imageKey + const imageIndex = this.items.indexOf(imageToAppendKey) + + this.items[imageIndex] = imageToAppendKey } return imageToAppendKey @@ -42,8 +44,6 @@ export class InMemoryImagesRepository implements ImagesRepository { async create(image: Image): Promise { this.items.push(image) - DomainEvents.dispatchEventsForAggregate(image.id) - return image } } diff --git a/test/repositories/in-memory-videos-repository.ts b/test/repositories/in-memory-videos-repository.ts index 05a8a60..fb14584 100644 --- a/test/repositories/in-memory-videos-repository.ts +++ b/test/repositories/in-memory-videos-repository.ts @@ -1,4 +1,3 @@ -import { DomainEvents } from '@/core/events/domain-events' import { type VideosRepository } from '@/domain/course-management/application/repositories/videos-repository' import { type Video } from '@/domain/course-management/enterprise/entities/video' @@ -34,6 +33,9 @@ export class InMemoryVideosRepository implements VideosRepository { if (!videoToAppendKey.videoKey) { videoToAppendKey.videoKey = videoKey + const videoIndex = this.items.indexOf(videoToAppendKey) + + this.items[videoIndex] = videoToAppendKey } return videoToAppendKey @@ -42,8 +44,6 @@ export class InMemoryVideosRepository implements VideosRepository { async create(video: Video): Promise