diff --git a/src/app.module.ts b/src/app.module.ts index 33255b5..f6adadd 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -34,6 +34,7 @@ import { UserOccupationModule } from './user-occupation/user-occupation.module'; import { UserStreakModule } from './user-streak/user-streak.module'; import { UserModule } from './user/user.module'; import { UserRewardModule } from './user-reward/user-reward.module'; +import { PretestModule } from './pretest/pretest.module'; @Module({ imports: [ @@ -79,6 +80,7 @@ import { UserRewardModule } from './user-reward/user-reward.module'; RewardModule, UserRewardModule, RoadmapModule, + PretestModule, ], controllers: [AppController], providers: [ diff --git a/src/chapter/chapter.controller.ts b/src/chapter/chapter.controller.ts index 06b51e8..ba94715 100644 --- a/src/chapter/chapter.controller.ts +++ b/src/chapter/chapter.controller.ts @@ -66,14 +66,13 @@ export class ChapterController { type: String, description: 'Course id', }) - @ApiBearerAuth() + @Public() @ApiResponse({ status: HttpStatus.OK, description: 'Get chapter video', type: StreamableFile, }) async getVideo( - @Req() request: AuthenticatedRequest, @Param( 'id', new ParseUUIDPipe({ @@ -83,11 +82,9 @@ export class ChapterController { ) id: string, ): Promise { - const chapter = await this.chapterService.findOneWithOwnership( - request.user.id, - request.user.role, - { where: { id } }, - ); + const chapter = await this.chapterService.findOne({ + where: { id }, + }); const file = await this.fileService.get(Folder.CHAPTER_VIDEOS, chapter.videoKey); return new StreamableFile(file, { diff --git a/src/enrollment/enrollment.service.ts b/src/enrollment/enrollment.service.ts index 764d5e9..815c94d 100644 --- a/src/enrollment/enrollment.service.ts +++ b/src/enrollment/enrollment.service.ts @@ -42,13 +42,6 @@ export class EnrollmentService { return enrollments; } - async findEnrollmentByUserId(userId: string): Promise { - const enrollment = this.enrollmentRepository.find({ - where: { user: { id: userId } }, - }); - return enrollment; - } - async findOne(where: FindOptionsWhere): Promise { const options: FindOneOptions = { where, diff --git a/src/exam-attempt/dtos/create-exam-attempt.dto.ts b/src/exam-attempt/dtos/create-exam-attempt.dto.ts index b1987ff..1851e5c 100644 --- a/src/exam-attempt/dtos/create-exam-attempt.dto.ts +++ b/src/exam-attempt/dtos/create-exam-attempt.dto.ts @@ -31,3 +31,33 @@ export class CreateExamAttemptDto { }) status: ExamAttemptStatus; } + +export class CreateExamAttemptPretestDto { + @IsNotEmpty() + @ApiProperty({ + description: 'Pretest ID', + type: String, + example: '8d4887aa-28e7-4d0e-844c-28a8ccead003', + }) + pretestId: string; + + @IsOptional() + @Min(0) + @IsInt() + @ApiProperty({ + description: 'Score', + type: Number, + example: 0, + }) + score?: number; + + @IsNotEmpty() + @IsEnum(ExamAttemptStatus) + @ApiProperty({ + description: 'Exam attempt status', + type: String, + example: ExamAttemptStatus.IN_PROGRESS, + enum: ExamAttemptStatus, + }) + status: ExamAttemptStatus; +} diff --git a/src/exam-attempt/dtos/exam-attempt-pretest.dto.ts b/src/exam-attempt/dtos/exam-attempt-pretest.dto.ts new file mode 100644 index 0000000..a44661c --- /dev/null +++ b/src/exam-attempt/dtos/exam-attempt-pretest.dto.ts @@ -0,0 +1,100 @@ +import { ExamAttemptStatus } from 'src/shared/enums'; +import { ApiProperty } from '@nestjs/swagger'; +import { PaginatedResponse } from 'src/shared/pagination/dtos/paginate-response.dto'; +import { UserResponseDto } from 'src/user/dtos/user-response.dto'; +import { ExamAttempt } from '../exam-attempt.entity'; +import { PretestResponseDto } from 'src/pretest/dtos/pretest-response.dto'; + +export class ExamAttemptPretestResponseDto { + @ApiProperty({ + description: 'Exam Attempy ID', + type: String, + example: '123e4567-e89b-12d3-a456-426614174000', + }) + id: string; + + @ApiProperty({ + description: 'Pretest Data', + type: PretestResponseDto, + example: { + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Exam title', + description: 'Exam description', + timeLimit: 20, + passingScore: 3, + maxAttempts: 1, + createdAt: '2024-11-26T11:10:00.257Z', + updatedAt: '2024-11-26T11:10:00.257Z', + }, + }) + pretest: PretestResponseDto; + + @ApiProperty({ + description: 'User Data', + type: String, + example: { + id: '389d1065-b898-4249-9d3d-e17e100336a7', + username: 'johndoe', + fullname: 'John Doe', + role: 'student', + email: 'johndoe@gmail.com', + profileKey: null, + }, + }) + user: UserResponseDto; + + @ApiProperty({ + description: 'Score', + type: String, + example: 0, + }) + score: number; + + @ApiProperty({ + description: 'Exam attempt status', + type: String, + example: ExamAttemptStatus.IN_PROGRESS, + enum: ExamAttemptStatus, + }) + status: ExamAttemptStatus; + + @ApiProperty({ + description: 'Exam attempt start at', + type: Date, + example: new Date(), + }) + startedAt: Date; + + @ApiProperty({ + description: 'Exam attempt submit at', + type: Date, + example: new Date(), + }) + submittedAt: Date; + + constructor(examAttempt: ExamAttempt) { + this.id = examAttempt.id; + this.pretest = examAttempt.pretest; + this.user = examAttempt.user; + this.score = examAttempt.score; + this.status = examAttempt.status; + this.startedAt = examAttempt.startedAt; + this.submittedAt = examAttempt.submittedAt; + } +} + +export class PaginatedExamAttemptPretestResponseDto extends PaginatedResponse( + ExamAttemptPretestResponseDto, +) { + constructor( + examAttempt: ExamAttempt[], + total: number, + pageSize: number, + currentPage: number, + ) { + const examAttemptDtos = examAttempt.map( + (examAttempt) => new ExamAttemptPretestResponseDto(examAttempt), + ); + super(examAttemptDtos, total, pageSize, currentPage); + } +} diff --git a/src/exam-attempt/dtos/update-exam-attempt.dto.ts b/src/exam-attempt/dtos/update-exam-attempt.dto.ts index c6a0cf3..424bbdb 100644 --- a/src/exam-attempt/dtos/update-exam-attempt.dto.ts +++ b/src/exam-attempt/dtos/update-exam-attempt.dto.ts @@ -1,6 +1,13 @@ import { OmitType, PartialType } from '@nestjs/swagger'; -import { CreateExamAttemptDto } from './create-exam-attempt.dto'; +import { + CreateExamAttemptDto, + CreateExamAttemptPretestDto, +} from './create-exam-attempt.dto'; export class UpdateExamAttemptDto extends PartialType( OmitType(CreateExamAttemptDto, ['examId'] as const), ) {} + +export class UpdateExamAttemptPretestDto extends PartialType( + OmitType(CreateExamAttemptPretestDto, ['pretestId'] as const), +) {} diff --git a/src/exam-attempt/exam-attempt.controller.ts b/src/exam-attempt/exam-attempt.controller.ts index 55e2c2f..6dc1b74 100644 --- a/src/exam-attempt/exam-attempt.controller.ts +++ b/src/exam-attempt/exam-attempt.controller.ts @@ -23,9 +23,19 @@ import { PaginateQueryDto } from 'src/shared/pagination/dtos/paginate-query.dto' import { ExamAttemptService } from './exam-attempt.service.dto'; import { Roles } from 'src/shared/decorators/role.decorator'; import { Role } from 'src/shared/enums'; -import { CreateExamAttemptDto } from './dtos/create-exam-attempt.dto'; -import { UpdateExamAttemptDto } from './dtos/update-exam-attempt.dto'; +import { + CreateExamAttemptDto, + CreateExamAttemptPretestDto, +} from './dtos/create-exam-attempt.dto'; +import { + UpdateExamAttemptDto, + UpdateExamAttemptPretestDto, +} from './dtos/update-exam-attempt.dto'; import { Exam } from 'src/exam/exam.entity'; +import { + ExamAttemptPretestResponseDto, + PaginatedExamAttemptPretestResponseDto, +} from './dtos/exam-attempt-pretest.dto'; @Controller('exam-attempt') @ApiTags('ExamAttempt') @@ -74,6 +84,46 @@ export class ExamAttemptController { ); } + @Get('/pretest') + @ApiResponse({ + status: HttpStatus.OK, + description: 'Returns all exam-attempt pretest', + type: PaginatedExamAttemptPretestResponseDto, + isArray: true, + }) + @ApiQuery({ + name: 'page', + type: Number, + required: false, + description: 'Page number', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Items per page', + }) + @ApiQuery({ + name: 'search', + type: String, + required: false, + description: 'Search by title', + }) + async findAllExamAttemptPretest( + @Req() request: AuthenticatedRequest, + @Query() query: PaginateQueryDto, + ): Promise { + return await this.examAttemptService.findAllExamAttemptPretest( + request.user.id, + request.user.role, + { + page: query.page, + limit: query.limit, + search: query.search, + }, + ); + } + @Get(':id') @ApiResponse({ status: HttpStatus.OK, @@ -101,6 +151,33 @@ export class ExamAttemptController { return new ExamAttemptResponseDto(exam); } + @Get('/pretest/:id') + @ApiResponse({ + status: HttpStatus.OK, + description: 'Returns an exam-attempt pretest', + type: ExamAttemptPretestResponseDto, + }) + async findOneExamAttemptPretest( + @Req() request: AuthenticatedRequest, + @Param( + 'id', + new ParseUUIDPipe({ + version: '4', + errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }), + ) + id: string, + ): Promise { + const exam = await this.examAttemptService.findOneExamAttemptPrestest( + request.user.id, + request.user.role, + { + where: { id }, + }, + ); + return new ExamAttemptPretestResponseDto(exam); + } + @Post() @Roles(Role.STUDENT) @ApiResponse({ @@ -120,11 +197,58 @@ export class ExamAttemptController { return new ExamAttemptResponseDto(exam); } + @Post('/pretest') + @Roles(Role.STUDENT) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Create an exam-attempt pretest', + type: ExamAttemptPretestResponseDto, + }) + @HttpCode(HttpStatus.CREATED) + async createExamAttemptPretest( + @Req() request: AuthenticatedRequest, + @Body() createExamAttemptPretestDto: CreateExamAttemptPretestDto, + ): Promise { + const exam = await this.examAttemptService.createExamAttemptPretest( + request.user.id, + createExamAttemptPretestDto, + ); + return new ExamAttemptPretestResponseDto(exam); + } + @Patch(':id') @Roles(Role.STUDENT) @ApiResponse({ status: HttpStatus.OK, description: 'Update an exam-attempt', + type: ExamAttemptPretestResponseDto, + }) + async updateExamAttemptPretest( + @Req() request: AuthenticatedRequest, + @Param( + 'id', + new ParseUUIDPipe({ + version: '4', + errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }), + ) + id: string, + @Body() updateExamAttemptPressDto: UpdateExamAttemptPretestDto, + ): Promise { + const exam = await this.examAttemptService.updateExamAttempt( + request.user.id, + request.user.role, + id, + updateExamAttemptPressDto, + ); + return new ExamAttemptPretestResponseDto(exam); + } + + @Patch('/pretest/:id') + @Roles(Role.STUDENT) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Update an exam-attempt ptetest', type: ExamAttemptResponseDto, }) async updateExamAttempt( @@ -138,14 +262,14 @@ export class ExamAttemptController { ) id: string, @Body() updateExamAttemptDto: UpdateExamAttemptDto, - ): Promise { - const exam = await this.examAttemptService.updateExamAttempt( + ): Promise { + const exam = await this.examAttemptService.updateExamAttemptPretest( request.user.id, request.user.role, id, updateExamAttemptDto, ); - return new ExamAttemptResponseDto(exam); + return new ExamAttemptPretestResponseDto(exam); } @Patch('/submit/:id') @@ -174,6 +298,32 @@ export class ExamAttemptController { return new ExamAttemptResponseDto(exam); } + @Patch('/pretest/submit/:id') + @Roles(Role.STUDENT) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Update an exam-attempt pretest', + type: ExamAttemptPretestResponseDto, + }) + async submitExamAttemptPretest( + @Req() request: AuthenticatedRequest, + @Param( + 'id', + new ParseUUIDPipe({ + version: '4', + errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }), + ) + id: string, + ): Promise { + const exam = await this.examAttemptService.submittedExamPretest( + request.user.id, + request.user.role, + id, + ); + return new ExamAttemptPretestResponseDto(exam); + } + @Delete(':id') @Roles(Role.TEACHER) @Roles(Role.ADMIN) @@ -200,4 +350,30 @@ export class ExamAttemptController { ); return new ExamAttemptResponseDto(examAttempt); } + + @Delete('/pretest/:id') + @Roles(Role.ADMIN) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Delete an exam', + type: ExamAttemptPretestResponseDto, + }) + async deleteExamAttemptPretest( + @Req() request: AuthenticatedRequest, + @Param( + 'id', + new ParseUUIDPipe({ + version: '4', + errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }), + ) + id: string, + ): Promise { + const examAttempt = await this.examAttemptService.deleteExamAttemptPretest( + request.user.id, + request.user.role, + id, + ); + return new ExamAttemptPretestResponseDto(examAttempt); + } } diff --git a/src/exam-attempt/exam-attempt.entity.ts b/src/exam-attempt/exam-attempt.entity.ts index 440723a..4fa8bcf 100644 --- a/src/exam-attempt/exam-attempt.entity.ts +++ b/src/exam-attempt/exam-attempt.entity.ts @@ -1,5 +1,6 @@ import { ExamAnswer } from 'src/exam-answer/exam-answer.entity'; import { Exam } from 'src/exam/exam.entity'; +import { Pretest } from 'src/pretest/pretest.entity'; import { ExamAttemptStatus } from 'src/shared/enums'; import { User } from 'src/user/user.entity'; import { @@ -21,14 +22,24 @@ export class ExamAttempt { @ManyToOne(() => Exam, (exam) => exam.examAttempt, { onDelete: 'CASCADE', - nullable: false, + nullable: true, }) @JoinColumn({ name: 'exam_id' }) exam: Exam; - @Column({ name: 'exam_id' }) + @Column({ name: 'exam_id', nullable: true }) examId: String; + @ManyToOne(() => Pretest, (pretest) => pretest.examAttempt, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'pretest_id' }) + pretest: Pretest; + + @Column({ name: 'pretest_id', nullable: true }) + pretestId: String; + @ManyToOne(() => User, (user) => user.examAttempt, { onDelete: 'CASCADE', nullable: false, diff --git a/src/exam-attempt/exam-attempt.module.ts b/src/exam-attempt/exam-attempt.module.ts index de0f2eb..2c33fb3 100644 --- a/src/exam-attempt/exam-attempt.module.ts +++ b/src/exam-attempt/exam-attempt.module.ts @@ -7,11 +7,12 @@ import { examAttemptProviders } from './exam-attempt.providers'; import { ExamAttemptService } from './exam-attempt.service.dto'; import { Exam } from 'src/exam/exam.entity'; import { User } from 'src/user/user.entity'; +import { Pretest } from 'src/pretest/pretest.entity'; @Module({ imports: [ DatabaseModule, - TypeOrmModule.forFeature([ExamAttempt, Exam, User]), + TypeOrmModule.forFeature([ExamAttempt, Exam, User, Pretest]), ], controllers: [ExamAttemptController], providers: [...examAttemptProviders, ExamAttemptService], diff --git a/src/exam-attempt/exam-attempt.service.dto.ts b/src/exam-attempt/exam-attempt.service.dto.ts index f3c2003..20e362e 100644 --- a/src/exam-attempt/exam-attempt.service.dto.ts +++ b/src/exam-attempt/exam-attempt.service.dto.ts @@ -23,10 +23,18 @@ import { ExamStatus, Role, } from 'src/shared/enums'; -import { CreateExamAttemptDto } from './dtos/create-exam-attempt.dto'; +import { + CreateExamAttemptDto, + CreateExamAttemptPretestDto, +} from './dtos/create-exam-attempt.dto'; import { Exam } from 'src/exam/exam.entity'; -import { UpdateExamAttemptDto } from './dtos/update-exam-attempt.dto'; +import { + UpdateExamAttemptDto, + UpdateExamAttemptPretestDto, +} from './dtos/update-exam-attempt.dto'; import { User } from 'src/user/user.entity'; +import { Pretest } from 'src/pretest/pretest.entity'; +import { PaginatedExamAttemptPretestResponseDto } from './dtos/exam-attempt-pretest.dto'; @Injectable() export class ExamAttemptService { @@ -37,6 +45,8 @@ export class ExamAttemptService { private readonly examRepository: Repository, @Inject('UserRepository') private readonly userRepository: Repository, + @Inject('PretestRepository') + private readonly pretestRepository: Repository, ) {} async findAll( @@ -191,8 +201,10 @@ export class ExamAttemptService { if (user.role != Role.STUDENT) throw new ForbiddenException('User is not student.'); if ( - (await this.countExamAttempts(createExamAttemptDto.examId, userId)) >= - exam.maxAttempts + (await this.countExamAttemptsWithExamId( + createExamAttemptDto.examId, + userId, + )) >= exam.maxAttempts ) throw new ForbiddenException( "Can't create exam-attempt more than max attempt", @@ -216,6 +228,8 @@ export class ExamAttemptService { const examAttemptInData = await this.findOne(userId, role, { where: { id }, }); + if (examAttemptInData.submittedAt) + throw new ForbiddenException('Already submitted'); if ( examAttemptInData.status != ExamAttemptStatus.IN_PROGRESS && updateExamAttemptDto.status == ExamAttemptStatus.IN_PROGRESS @@ -272,7 +286,10 @@ export class ExamAttemptService { return updatedExamAttempt; } - async countExamAttempts(examId: string, userId: string): Promise { + async countExamAttemptsWithExamId( + examId: string, + userId: string, + ): Promise { const count = await this.examAttemptRepository .createQueryBuilder('examAttempt') .where('examAttempt.examId = :examId AND examAttempt.userId = :userId', { @@ -307,4 +324,255 @@ export class ExamAttemptService { profileKey: true, }; } + + async findAllExamAttemptPretest( + userId: string, + role: Role, + { + page = 1, + limit = 20, + search = '', + }: { + page?: number; + limit?: number; + search?: string; + }, + ): Promise { + const { find } = await createPagination(this.examAttemptRepository, { + page, + limit, + }); + + const whereCondition = this.validateAndCreateConditionForPretest( + userId, + role, + search, + ); + const exam = await find({ + where: whereCondition, + relations: ['pretest', 'user'], + select: { + user: this.selectPopulateUser(), + pretest: this.selectPopulatePretest(), + }, + }).run(); + + return exam; + } + + async findOneExamAttemptPrestest( + userId: string, + role: Role, + options: FindOneOptions = {}, + ): Promise { + const whereCondition = this.validateAndCreateConditionForPretest( + userId, + role, + '', + ); + + const where = Array.isArray(whereCondition) + ? [ + { ...whereCondition[0], ...options.where }, + { ...whereCondition[1], ...options.where }, + ] + : { ...whereCondition, ...options.where }; + + const exam = await this.examAttemptRepository.findOne({ + ...options, + where, + relations: ['pretest', 'user'], + select: { + user: this.selectPopulateUser(), + pretest: this.selectPopulatePretest(), + }, + }); + + if (!exam) { + throw new NotFoundException('Exam not found'); + } + + return exam; + } + + private validateAndCreateConditionForPretest( + userId: string, + role: Role, + search: string, + ): FindOptionsWhere | FindOptionsWhere[] { + const baseSearch = search ? { id: ILike(`%${search}%`) } : {}; + + if (role === Role.ADMIN) { + return { ...baseSearch }; + } + + if (role === Role.STUDENT) { + return { + ...baseSearch, + pretest: { + user: { + id: userId, + }, + }, + }; + } + + return { + ...baseSearch, + pretest: { + user: { + id: userId, + }, + }, + }; + } + + async createExamAttemptPretest( + userId: string, + createExamAttemptPretestDto: CreateExamAttemptPretestDto, + ): Promise { + const pretest = await this.pretestRepository.findOne({ + where: { id: createExamAttemptPretestDto.pretestId }, + select: this.selectPopulatePretest(), + }); + if (!pretest) { + throw new NotFoundException('Pretest not found.'); + } + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: this.selectPopulateUser(), + }); + if (!user) { + throw new NotFoundException('User not found.'); + } + if (user.role != Role.STUDENT) + throw new ForbiddenException('User is not student.'); + if ( + (await this.countExamAttemptsWithPretestId( + createExamAttemptPretestDto.pretestId, + userId, + )) >= pretest.maxAttempts + ) + throw new ForbiddenException( + "Can't create exam-attempt more than max attempt", + ); + const examAttempt = await this.examAttemptRepository.create({ + ...createExamAttemptPretestDto, + pretest, + user, + }); + if (!examAttempt) throw new NotFoundException("Can't create exam-attempt"); + await this.examAttemptRepository.save(examAttempt); + return examAttempt; + } + + async updateExamAttemptPretest( + userId: string, + role: Role, + id: string, + updateExamAttemptPretestDto: UpdateExamAttemptPretestDto, + ): Promise { + const examAttemptInData = await this.findOneExamAttemptPrestest( + userId, + role, + { + where: { id }, + }, + ); + if (examAttemptInData.submittedAt) + throw new ForbiddenException('Already submitted'); + if ( + examAttemptInData.status != ExamAttemptStatus.IN_PROGRESS && + updateExamAttemptPretestDto.status == ExamAttemptStatus.IN_PROGRESS + ) { + throw new ForbiddenException("Can't change status to in progress"); + } + const examAttempt = await this.examAttemptRepository.update( + id, + updateExamAttemptPretestDto, + ); + if (!examAttempt) + throw new BadRequestException("Can't update exam-attempt"); + return await this.examAttemptRepository.findOne({ + where: { id }, + relations: ['pretest', 'user'], + select: { + user: this.selectPopulateUser(), + pretest: this.selectPopulatePretest(), + }, + }); + } + + async deleteExamAttemptPretest( + userId: string, + role: Role, + id: string, + ): Promise { + try { + const examAttempt = await this.findOneExamAttemptPrestest(userId, role, { + where: { id }, + }); + return await this.examAttemptRepository.remove(examAttempt); + } catch (error) { + if (error instanceof Error) + throw new NotFoundException('Exam-attempt not found'); + } + } + + async submittedExamPretest( + userId: string, + role: Role, + id: string, + ): Promise { + const examAttemptInData = await this.findOneExamAttemptPrestest( + userId, + role, + { + where: { id }, + }, + ); + + examAttemptInData.submittedAt = new Date(); + + await this.examAttemptRepository.update(id, examAttemptInData); + + const updatedExamAttempt = await this.findOneExamAttemptPrestest( + userId, + role, + { + where: { id }, + }, + ); + + return updatedExamAttempt; + } + + async countExamAttemptsWithPretestId( + pretestId: string, + userId: string, + ): Promise { + const count = await this.examAttemptRepository + .createQueryBuilder('examAttempt') + .where( + 'examAttempt.pretestId = :pretestId AND examAttempt.userId = :userId', + { + pretestId, + userId, + }, + ) + .getCount(); + + return count; + } + + private selectPopulatePretest(): FindOptionsSelect { + return { + id: true, + title: true, + description: true, + timeLimit: true, + passingScore: true, + maxAttempts: true, + }; + } } diff --git a/src/exam/dtos/create-exam.dto.ts b/src/exam/dtos/create-exam.dto.ts index 47919e0..08cf1af 100644 --- a/src/exam/dtos/create-exam.dto.ts +++ b/src/exam/dtos/create-exam.dto.ts @@ -39,7 +39,7 @@ export class CreateExamDto { type: Number, example: 20, }) - timeLimit: number = 20; + timeLimit: number; @IsNotEmpty() @IsInt() diff --git a/src/exam/exam.controller.ts b/src/exam/exam.controller.ts index 10d357f..effb96a 100644 --- a/src/exam/exam.controller.ts +++ b/src/exam/exam.controller.ts @@ -31,11 +31,6 @@ import { CreateExamDto } from './dtos/create-exam.dto'; import { PaginateQueryDto } from 'src/shared/pagination/dtos/paginate-query.dto'; import { AuthenticatedRequest } from 'src/auth/interfaces/authenticated-request.interface'; import { UpdateExamDto } from './dtos/update-exam.dto'; -import { - PaginatedQuestionResponseDto, - QuestionResponseDto, -} from 'src/question/dtos/question-response.dto'; -import { Public } from 'src/shared/decorators/public.decorator'; @Controller('exam') @ApiTags('Exam') @@ -48,7 +43,7 @@ export class ExamController { @ApiResponse({ status: HttpStatus.OK, description: 'Returns all exams', - type: PaginatedQuestionResponseDto, + type: PaginatedExamResponseDto, isArray: true, }) @ApiQuery({ @@ -174,28 +169,4 @@ export class ExamController { ); return new ExamResponseDto(exam); } - - @Post('generate/:examId') - @ApiResponse({ - status: HttpStatus.CREATED, - description: 'Create an question and question option in exam', - }) - @HttpCode(HttpStatus.CREATED) - async createQuestionAndChoice( - @Req() request: AuthenticatedRequest, - @Param( - 'examId', - new ParseUUIDPipe({ - version: '4', - errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, - }), - ) - examId: string, - ): Promise { - const questions = await this.examService.createQuestionAndChoice( - examId, - request.user.id, - ); - return questions.map((question) => new QuestionResponseDto(question)); - } } diff --git a/src/exam/exam.entity.ts b/src/exam/exam.entity.ts index 6bc2aaf..c173b42 100644 --- a/src/exam/exam.entity.ts +++ b/src/exam/exam.entity.ts @@ -52,7 +52,7 @@ export class Exam { nullable: false, default: 20, }) - timeLimit: number; + timeLimit: number = 20; @Column({ nullable: false, diff --git a/src/exam/exam.module.ts b/src/exam/exam.module.ts index 73d7cb7..39b0384 100644 --- a/src/exam/exam.module.ts +++ b/src/exam/exam.module.ts @@ -6,25 +6,9 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Exam } from './exam.entity'; import { examProviders } from './exam.providers'; import { CourseModule } from 'src/course-module/course-module.entity'; -import { QuestionModule } from 'src/question/question.module'; -import { QuestionOptionModule } from 'src/question-option/question-option.module'; -import { ExamAnswerModule } from 'src/exam-answer/exam-answer.module'; -import { HttpModule } from '@nestjs/axios'; -import { UserModule } from 'src/user/user.module'; -import { EnrollmentModule } from 'src/enrollment/enrollment.module'; -import { ConfigModule } from '@nestjs/config'; @Module({ - imports: [ - DatabaseModule, - TypeOrmModule.forFeature([Exam, CourseModule]), - QuestionModule, - QuestionOptionModule, - HttpModule, - UserModule, - EnrollmentModule, - ConfigModule, - ], + imports: [DatabaseModule, TypeOrmModule.forFeature([Exam, CourseModule])], controllers: [ExamController], providers: [...examProviders, ExamService], exports: [ExamService], diff --git a/src/exam/exam.service.ts b/src/exam/exam.service.ts index 8128ed9..59ecc3e 100644 --- a/src/exam/exam.service.ts +++ b/src/exam/exam.service.ts @@ -16,19 +16,9 @@ import { Exam } from './exam.entity'; import { CreateExamDto } from './dtos/create-exam.dto'; import { PaginatedExamResponseDto } from './dtos/exam-response.dto'; import { createPagination } from 'src/shared/pagination'; -import { CourseStatus, ExamStatus, QuestionType, Role } from 'src/shared/enums'; +import { CourseStatus, ExamStatus, Role } from 'src/shared/enums'; import { UpdateExamDto } from './dtos/update-exam.dto'; import { CourseModule } from 'src/course-module/course-module.entity'; -import { QuestionService } from 'src/question/question.service'; -import { QuestionOptionService } from 'src/question-option/question-option.service'; -import { HttpService } from '@nestjs/axios'; -import { AuthenticatedRequest } from 'src/auth/interfaces/authenticated-request.interface'; -import { UserService } from 'src/user/user.service'; -import { EnrollmentService } from 'src/enrollment/enrollment.service'; -import { PretestDto } from './dtos/pretest.dto'; -import { Question } from 'src/question/question.entity'; -import { ConfigService } from '@nestjs/config'; -import { GLOBAL_CONFIG } from 'src/shared/constants/global-config.constant'; @Injectable() export class ExamService { @@ -37,12 +27,6 @@ export class ExamService { private readonly examRepository: Repository, @Inject('CourseModuleRepository') private readonly courseModuleRepository: Repository, - private readonly questionService: QuestionService, - private readonly questionOptionService: QuestionOptionService, - private readonly httpService: HttpService, - private readonly userService: UserService, - private readonly enrollService: EnrollmentService, - private readonly configService: ConfigService, ) {} async findAll( @@ -213,8 +197,6 @@ export class ExamService { examInData.status != ExamStatus.DRAFT && updateExamDto.status == ExamStatus.DRAFT ) { - console.log(examInData.status); - console.log(updateExamDto.status); throw new ForbiddenException("Can't change status to draft"); } @@ -269,104 +251,4 @@ export class ExamService { return false; } } - - async fetchData(examId: string, userId: string): Promise { - const user = await this.userService.findOne({ where: { id: userId } }); - if (!user) throw new NotFoundException('Not Found User'); - const exam = await this.examRepository.findOne({ where: { id: examId } }); - if (!exam) throw new NotFoundException('Not Found this exam'); - const enrollments = await this.enrollService.findEnrollmentByUserId(userId); - try { - const requestBody = { - id: exam.id, - user: { - id: user.id, - email: user.email, - points: user.points, - role: user.role, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - fullname: user.fullname, - }, - occupation: { - id: exam.id, - title: exam.title, - description: exam.description, - createdAt: exam.createdAt, - updatedAt: exam.updatedAt, - }, - createdAt: new Date(), - updatedAt: new Date(), - - topics: - enrollments.length > 0 - ? enrollments.map((enrollment) => ({ - id: enrollment.id, - title: enrollment.course.title, - description: enrollment.course.description, - level: enrollment.course.level, - createdAt: enrollment.createdAt, - updatedAt: enrollment.updatedAt, - })) - : [], - }; - - console.log(requestBody); - - console.log(enrollments); - - const response = await this.httpService.axiosRef.post( - `${this.configService.get( - GLOBAL_CONFIG.AI_URL, - )}/generate-pretest/`, - requestBody, - ); - return { data: response.data }; - } catch (error) { - throw new Error('Failed to fetch data or process request'); - } - } - - async createQuestionAndChoice( - examId: string, - userId: string, - ): Promise { - let questions = []; - const fetchData = await this.fetchData(examId, userId); - let orderIndex = (await this.questionService.getMaxOrderIndex(examId)) + 1; - await Promise.all( - fetchData.data.map(async (data) => { - const createQuestionDto = { - examId, - question: data.question, - type: QuestionType.MULTIPLE_CHOICE, - points: 1, - orderIndex: orderIndex++, - }; - - const question = await this.questionService.createQuestion( - createQuestionDto, - ); - - questions.push(question); - - await Promise.all( - Object.entries(data.choices).map(([key, value]) => { - const createQuestionOptionDto = { - questionId: question.id, - optionText: `${key}. ${value}`, - isCorrect: key === data.answer, - explanation: '', - }; - - return this.questionOptionService.createQuestionOption( - createQuestionOptionDto, - ); - }), - ); - }), - ); - - return questions; - } } diff --git a/src/pretest/dtos/create-pretest.dto.ts b/src/pretest/dtos/create-pretest.dto.ts new file mode 100644 index 0000000..daecd40 --- /dev/null +++ b/src/pretest/dtos/create-pretest.dto.ts @@ -0,0 +1,46 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsInt, IsNotEmpty, IsOptional } from 'class-validator'; +export class CreatePretestDto { + @IsNotEmpty() + @ApiProperty({ + description: 'Exam title', + type: String, + example: 'Biology', + }) + title: string; + + @IsOptional() + @ApiProperty({ + description: 'Exam description', + type: String, + example: 'This course is an introduction to biology', + }) + description?: string; + + @IsNotEmpty() + @IsInt() + @ApiProperty({ + description: 'time limit to do exam.', + type: Number, + example: 20, + }) + timeLimit: number; + + @IsNotEmpty() + @IsInt() + @ApiProperty({ + description: 'Score to pass exam.', + type: Number, + example: 3, + }) + passingScore: number; + + @IsNotEmpty() + @IsInt() + @ApiProperty({ + description: 'Max attempts to do exam.', + type: Number, + example: 1, + }) + maxAttempts: number; +} diff --git a/src/pretest/dtos/pretest-response.dto.ts b/src/pretest/dtos/pretest-response.dto.ts new file mode 100644 index 0000000..e8c620d --- /dev/null +++ b/src/pretest/dtos/pretest-response.dto.ts @@ -0,0 +1,104 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { UserResponseDto } from 'src/user/dtos/user-response.dto'; +import { Pretest } from '../pretest.entity'; +import { PaginatedResponse } from 'src/shared/pagination/dtos/paginate-response.dto'; + +export class PretestResponseDto { + @ApiProperty({ + description: 'Pretest ID', + type: String, + example: '123e4567-e89b-12d3-a456-426614174000', + }) + id: string; + + @ApiProperty({ + description: 'User Data', + type: String, + example: { + id: '389d1065-b898-4249-9d3d-e17e100336a7', + username: 'johndoe', + fullname: 'John Doe', + role: 'student', + email: 'johndoe@gmail.com', + profileKey: null, + }, + }) + user: UserResponseDto; + + @ApiProperty({ + description: 'Exam title', + type: String, + example: 'Exam title', + }) + title: string; + + @ApiProperty({ + description: 'Exam description', + type: String, + example: 'Exam description', + }) + description: string; + + @ApiProperty({ + description: 'Timelimit to do exam.', + type: Number, + example: 20, + }) + timeLimit: Number; + + @ApiProperty({ + description: 'Score to pass exam.', + type: Number, + example: 3, + }) + passingScore: Number; + + @ApiProperty({ + description: 'Max attempts to do exam.', + type: Number, + example: 1, + }) + maxAttempts: Number; + + @ApiProperty({ + description: 'Exam created date', + type: Date, + example: new Date(), + }) + createdAt: Date; + + @ApiProperty({ + description: 'Exam updated date', + type: Date, + example: new Date(), + }) + updatedAt: Date; + + constructor(pretest: Pretest) { + this.id = pretest.id; + this.user = pretest.user; + this.title = pretest.title; + this.description = pretest.description; + this.timeLimit = pretest.timeLimit; + this.passingScore = pretest.passingScore; + this.maxAttempts = pretest.maxAttempts; + this.createdAt = pretest.createdAt; + this.updatedAt = pretest.updatedAt; + } +} + +export class PaginatedPretestResponseDto extends PaginatedResponse( + PretestResponseDto, +) { + constructor( + pretest: Pretest[], + total: number, + pageSize: number, + currentPage: number, + ) { + const pretestDtos = pretest.map( + (pretest) => new PretestResponseDto(pretest), + ); + super(pretestDtos, total, pageSize, currentPage); + } +} diff --git a/src/exam/dtos/pretest.dto.ts b/src/pretest/dtos/pretest.dto.ts similarity index 100% rename from src/exam/dtos/pretest.dto.ts rename to src/pretest/dtos/pretest.dto.ts diff --git a/src/pretest/dtos/update-pretest.dto.ts b/src/pretest/dtos/update-pretest.dto.ts new file mode 100644 index 0000000..3a72746 --- /dev/null +++ b/src/pretest/dtos/update-pretest.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreatePretestDto } from './create-pretest.dto'; + +export class UpdatePretestDto extends PartialType(CreatePretestDto) {} diff --git a/src/pretest/pretest.controller.ts b/src/pretest/pretest.controller.ts new file mode 100644 index 0000000..cf11247 --- /dev/null +++ b/src/pretest/pretest.controller.ts @@ -0,0 +1,177 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Injectable, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { PretestService } from './pretest.service'; +import { + PaginatedPretestResponseDto, + PretestResponseDto, +} from './dtos/pretest-response.dto'; +import { AuthenticatedRequest } from 'src/auth/interfaces/authenticated-request.interface'; +import { PaginateQueryDto } from 'src/shared/pagination/dtos/paginate-query.dto'; +import { Roles } from 'src/shared/decorators/role.decorator'; +import { Role } from 'src/shared/enums'; +import { CreatePretestDto } from './dtos/create-pretest.dto'; +import { UpdatePretestDto } from './dtos/update-pretest.dto'; + +@Controller('pretest') +@ApiTags('Pretest') +@ApiBearerAuth() +@Injectable() +export class PretestController { + constructor(private readonly pretestService: PretestService) {} + @Get() + @Roles(Role.STUDENT) + @Roles(Role.ADMIN) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Returns all pretests', + type: PaginatedPretestResponseDto, + isArray: true, + }) + @ApiQuery({ + name: 'page', + type: Number, + required: false, + description: 'Page number', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Items per page', + }) + @ApiQuery({ + name: 'search', + type: String, + required: false, + description: 'Search by title', + }) + async findAll( + @Req() request: AuthenticatedRequest, + @Query() query: PaginateQueryDto, + ): Promise { + return await this.pretestService.findAll( + request.user.id, + request.user.role, + { + page: query.page, + limit: query.limit, + search: query.search, + }, + ); + } + + @Get(':id') + @Roles(Role.STUDENT) + @Roles(Role.ADMIN) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Returns a pretest', + type: PretestResponseDto, + }) + async findOne( + @Req() request: AuthenticatedRequest, + @Param( + 'id', + new ParseUUIDPipe({ + version: '4', + errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }), + ) + id: string, + ): Promise { + const pretest = await this.pretestService.findOne( + request.user.id, + request.user.role, + { where: { id } }, + ); + return new PretestResponseDto(pretest); + } + + @Post() + @Roles(Role.STUDENT) + @ApiResponse({ + status: HttpStatus.CREATED, + description: 'Create a pretest', + type: PretestResponseDto, + }) + @HttpCode(HttpStatus.CREATED) + async createPretest( + @Req() request: AuthenticatedRequest, + @Body() createPretestDto: CreatePretestDto, + ): Promise { + const pretest = await this.pretestService.createPretest( + request.user.id, + createPretestDto, + ); + return new PretestResponseDto(pretest); + } + + @Patch(':id') + @Roles(Role.STUDENT) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Update a pretest', + type: PretestResponseDto, + }) + async updatePretest( + @Req() request: AuthenticatedRequest, + @Param( + 'id', + new ParseUUIDPipe({ + version: '4', + errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }), + ) + id: string, + @Body() updatePretestDto: UpdatePretestDto, + ): Promise { + const pretest = await this.pretestService.updatePretest( + request.user.id, + request.user.role, + id, + updatePretestDto, + ); + return new PretestResponseDto(pretest); + } + + @Delete(':id') + @Roles(Role.STUDENT) + @Roles(Role.ADMIN) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Delete a pretest', + type: PretestResponseDto, + }) + async deleteExam( + @Req() request: AuthenticatedRequest, + @Param( + 'id', + new ParseUUIDPipe({ + version: '4', + errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }), + ) + id: string, + ): Promise { + const pretest = await this.pretestService.deletePretest( + request.user.id, + request.user.role, + id, + ); + return new PretestResponseDto(pretest); + } +} diff --git a/src/pretest/pretest.entity.ts b/src/pretest/pretest.entity.ts new file mode 100644 index 0000000..192e926 --- /dev/null +++ b/src/pretest/pretest.entity.ts @@ -0,0 +1,75 @@ +import { ExamAttempt } from 'src/exam-attempt/exam-attempt.entity'; +import { Question } from 'src/question/question.entity'; +import { User } from 'src/user/user.entity'; +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity() +export class Pretest { + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => User, (user) => user.pretest, { + onDelete: 'CASCADE', + nullable: false, + }) + @JoinColumn({ name: 'user_id' }) + user: User; + + @Column({ name: 'user_id' }) + userId: String; + + @OneToMany(() => ExamAttempt, (examAttempt) => examAttempt.pretest, { + cascade: true, + }) + examAttempt: ExamAttempt[]; + + @OneToMany(() => Question, (question) => question.pretest, { + cascade: true, + }) + question: Question[]; + + @Column({ + nullable: false, + }) + title: string; + + @Column({ + nullable: true, + }) + description: string; + + @Column({ + nullable: false, + default: 20, + }) + timeLimit: number = 20; + + @Column({ + nullable: false, + }) + passingScore: number; + + @Column({ + nullable: false, + }) + maxAttempts: number; + + @CreateDateColumn({ + type: 'timestamp with time zone', + }) + createdAt: Date; + + @UpdateDateColumn({ + type: 'timestamp with time zone', + }) + updatedAt: Date; +} diff --git a/src/pretest/pretest.module.ts b/src/pretest/pretest.module.ts new file mode 100644 index 0000000..70f6251 --- /dev/null +++ b/src/pretest/pretest.module.ts @@ -0,0 +1,30 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { DatabaseModule } from 'src/database/database.module'; +import { Pretest } from './pretest.entity'; +import { PretestController } from './pretest.controller'; +import { pretestProviders } from './pretest.providers'; +import { PretestService } from './pretest.service'; +import { User } from 'src/user/user.entity'; +import { UserModule } from 'src/user/user.module'; +import { UserBackgroundModule } from 'src/user-background/user-background.module'; +import { HttpModule } from '@nestjs/axios'; +import { ConfigModule } from '@nestjs/config'; +import { QuestionModule } from 'src/question/question.module'; +import { QuestionOptionModule } from 'src/question-option/question-option.module'; + +@Module({ + imports: [ + DatabaseModule, + TypeOrmModule.forFeature([Pretest, User]), + UserBackgroundModule, + HttpModule, + ConfigModule, + QuestionModule, + QuestionOptionModule, + ], + controllers: [PretestController], + providers: [...pretestProviders, PretestService], + exports: [PretestService], +}) +export class PretestModule {} diff --git a/src/pretest/pretest.providers.ts b/src/pretest/pretest.providers.ts new file mode 100644 index 0000000..9eb7021 --- /dev/null +++ b/src/pretest/pretest.providers.ts @@ -0,0 +1,10 @@ +import { DataSource } from 'typeorm'; +import { Pretest } from './pretest.entity'; + +export const pretestProviders = [ + { + provide: 'PretestRepository', + useFactory: (dataSource: DataSource) => dataSource.getRepository(Pretest), + inject: ['DataSource'], + }, +]; diff --git a/src/pretest/pretest.service.ts b/src/pretest/pretest.service.ts new file mode 100644 index 0000000..cf2e449 --- /dev/null +++ b/src/pretest/pretest.service.ts @@ -0,0 +1,289 @@ +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Pretest } from './pretest.entity'; +import { + FindOneOptions, + FindOptionsSelect, + FindOptionsWhere, + ILike, + Repository, +} from 'typeorm'; +import { PaginatedPretestResponseDto } from './dtos/pretest-response.dto'; +import { createPagination } from 'src/shared/pagination'; +import { User } from 'src/user/user.entity'; +import { QuestionType, Role } from 'src/shared/enums'; +import { CreatePretestDto } from './dtos/create-pretest.dto'; +import { UpdatePretestDto } from './dtos/update-pretest.dto'; +import { UserService } from 'src/user/user.service'; +import { UserBackgroundService } from 'src/user-background/user-background.service'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { PretestDto } from './dtos/pretest.dto'; +import { GLOBAL_CONFIG } from 'src/shared/constants/global-config.constant'; +import { QuestionService } from 'src/question/question.service'; +import { QuestionOptionService } from 'src/question-option/question-option.service'; + +@Injectable() +export class PretestService { + constructor( + @Inject('PretestRepository') + private readonly pretestRepository: Repository, + @Inject('UserRepository') + private readonly userRepository: Repository, + private readonly userBackgroundService: UserBackgroundService, + private readonly httpService: HttpService, + private readonly configService: ConfigService, + private readonly questionService: QuestionService, + private readonly questionOptionService: QuestionOptionService, + ) {} + async findAll( + userId: string, + role: Role, + { + page = 1, + limit = 20, + search = '', + }: { + page?: number; + limit?: number; + search?: string; + }, + ): Promise { + const { find } = await createPagination(this.pretestRepository, { + page, + limit, + }); + + const whereCondition = this.validateAndCreateCondition( + userId, + role, + search, + ); + const pretest = await find({ + where: whereCondition, + relations: ['user'], + select: { + user: this.selectPopulateUser(), + }, + }).run(); + + return new PaginatedPretestResponseDto( + pretest.data, + pretest.meta.total, + pretest.meta.pageSize, + pretest.meta.currentPage, + ); + } + + async findOne( + userId: string, + role: Role, + options: FindOneOptions = {}, + ): Promise { + const whereCondition = this.validateAndCreateCondition(userId, role, ''); + + const where = Array.isArray(whereCondition) + ? [ + { ...whereCondition[0], ...options.where }, + { ...whereCondition[1], ...options.where }, + ] + : { ...whereCondition, ...options.where }; + + const pretest = await this.pretestRepository.findOne({ + ...options, + where, + relations: ['user'], + select: { + user: this.selectPopulateUser(), + }, + }); + + if (!pretest) { + throw new NotFoundException('Pretest not found'); + } + + return pretest; + } + + private validateAndCreateCondition( + userId: string, + role: Role, + search: string, + ): FindOptionsWhere | FindOptionsWhere[] { + const baseSearch = search ? { title: ILike(`%${search}%`) } : {}; + + if (role === Role.ADMIN) { + return { + ...baseSearch, + }; + } + if (role === Role.STUDENT) { + return { + ...baseSearch, + user: { + id: userId, + }, + }; + } + return { + ...baseSearch, + user: { + id: userId, + }, + }; + } + + async createPretest( + userId: string, + createPretestDto: CreatePretestDto, + ): Promise { + const user = await this.userRepository.findOne({ + where: { id: userId }, + select: this.selectPopulateUser(), + }); + + if (!user) throw new NotFoundException('User not found'); + + const pretest = await this.pretestRepository.create({ + ...createPretestDto, + user, + }); + + if (!pretest) throw new BadRequestException("Can't create pretest"); + await this.pretestRepository.save(pretest); + await this.createQuestionAndChoice(pretest.id, userId); + return pretest; + } + + async updatePretest( + userId: string, + role: Role, + id: string, + updatePretestDto: UpdatePretestDto, + ): Promise { + const pretestInData = await this.findOne(userId, role, { where: { id } }); + + const pretest = await this.pretestRepository.update(id, updatePretestDto); + if (!pretest) throw new BadRequestException("Can't update pretest"); + return await this.pretestRepository.findOne({ + where: { id }, + relations: ['user'], + select: { + user: this.selectPopulateUser(), + }, + }); + } + + async deletePretest( + userId: string, + role: Role, + id: string, + ): Promise { + try { + const pretest = await this.findOne(userId, role, { where: { id } }); + return await this.pretestRepository.remove(pretest); + } catch (error) { + if (error instanceof Error) + throw new NotFoundException('Pretest not found'); + } + } + + private selectPopulateUser(): FindOptionsSelect { + return { + id: true, + username: true, + fullname: true, + role: true, + email: true, + profileKey: true, + }; + } + + async fetchData(userId: string, pretestId: string): Promise { + const userBackground = await this.userBackgroundService.findOneByUserId( + userId, + ); + try { + const requestBody = { + id: pretestId, + user: { + id: userBackground.user.id, + email: userBackground.user.email, + points: userBackground.user.points, + role: userBackground.user.role, + createdAt: userBackground.user.createdAt, + updatedAt: userBackground.user.updatedAt, + fullname: userBackground.user.fullname, + }, + occupation: { + id: userBackground.occupation.id, + title: userBackground.occupation.title, + description: userBackground.occupation.description, + createdAt: userBackground.occupation.createdAt, + updatedAt: userBackground.occupation.updatedAt, + }, + createdAt: new Date(), + updatedAt: new Date(), + topics: + userBackground.topics.length > 0 + ? userBackground.topics.map((topic) => ({ + id: topic.id, + title: topic.title, + description: topic.description, + level: topic.level, + createdAt: topic.createdAt, + updatedAt: topic.updatedAt, + })) + : [], + }; + const response = await this.httpService.axiosRef.post( + `${this.configService.get( + GLOBAL_CONFIG.AI_URL, + )}/ai/generate-pretest/`, + requestBody, + ); + return { data: response.data }; + } catch (error) { + throw new Error('Failed to fetch data or process request'); + } + } + + async createQuestionAndChoice( + pretestId: string, + userId: string, + ): Promise { + const fetchData = await this.fetchData(userId, pretestId); + let orderIndex = 1; + await Promise.all( + fetchData.data.map(async (data) => { + const createQuestionDto = { + pretestId, + question: data.question, + type: QuestionType.PRETEST, + points: 1, + orderIndex: orderIndex++, + }; + const question = await this.questionService.createQuestionForPretest( + createQuestionDto, + ); + await Promise.all( + Object.entries(data.choices).map(([key, value]) => { + const createQuestionOptionDto = { + questionId: question.id, + optionText: `${key}. ${value}`, + isCorrect: key === data.answer, + explanation: '', + }; + return this.questionOptionService.createQuestionOptionPretest( + createQuestionOptionDto, + ); + }), + ); + }), + ); + } +} diff --git a/src/question-option/dtos/question-option-pretest-response.dto.ts b/src/question-option/dtos/question-option-pretest-response.dto.ts new file mode 100644 index 0000000..15b31e4 --- /dev/null +++ b/src/question-option/dtos/question-option-pretest-response.dto.ts @@ -0,0 +1,106 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { QuestionOption } from '../question-option.entity'; +import { PaginatedResponse } from 'src/shared/pagination/dtos/paginate-response.dto'; +import { QuestionPretestResponseDto } from 'src/question/dtos/question-pretest-response.dto'; + +export class QuestionOptionPretestResponseDto { + @ApiProperty({ + description: 'Question option ID', + type: String, + example: '123e4567-e89b-12d3-a456-426614174000', + }) + id: string; + + @ApiProperty({ + description: 'Question Data', + type: QuestionPretestResponseDto, + example: { + points: 1, + orderIndex: 10, + id: 'e99e39a0-edf0-470c-b8f1-9fe8fddd0ef3', + examId: null, + pretestId: '168eb5b6-9ba9-4e6d-9880-551d4232129e', + question: + "What is the difference between 'int()', 'float()', and 'str()' functions in Python?", + type: 'pretest', + pretest: { + timeLimit: 20, + id: '168eb5b6-9ba9-4e6d-9880-551d4232129e', + title: 'Biology', + description: 'This course is an introduction to biology', + passingScore: 3, + maxAttempts: 1, + user: { + id: 'a12e1e37-3504-4711-a389-09a1734d7b1c', + username: 'johndoe', + fullname: 'John Doe', + role: 'student', + email: 'johndoe@gmail.com', + profileKey: null, + }, + }, + }, + }) + question: QuestionPretestResponseDto; + + @ApiProperty({ + description: 'Question option text', + type: String, + example: 'A. Rock', + }) + optionText: string; + + @ApiProperty({ + description: 'Is this question option correct?', + type: Boolean, + example: false, + }) + isCorrect: boolean; + + @ApiProperty({ + description: 'ehy this question option correct or incorrect?', + type: String, + example: 'Rock is not biology.', + }) + explanation: string; + + @ApiProperty({ + description: 'Exam created date', + type: Date, + example: new Date(), + }) + createdAt: Date; + + @ApiProperty({ + description: 'Exam updated date', + type: Date, + example: new Date(), + }) + updatedAt: Date; + + constructor(questionOption: QuestionOption) { + this.id = questionOption.id; + this.question = questionOption.question; + this.optionText = questionOption.optionText; + this.isCorrect = questionOption.isCorrect; + this.explanation = questionOption.explanation; + this.createdAt = questionOption.createdAt; + this.updatedAt = questionOption.updatedAt; + } +} + +export class PaginatedQuestionOptionPretestResponseDto extends PaginatedResponse( + QuestionOptionPretestResponseDto, +) { + constructor( + questionOption: QuestionOption[], + total: number, + pageSize: number, + currentPage: number, + ) { + const questionOptionDtos = questionOption.map( + (questionOption) => new QuestionOptionPretestResponseDto(questionOption), + ); + super(questionOptionDtos, total, pageSize, currentPage); + } +} diff --git a/src/question-option/dtos/question-option-response.dto.ts b/src/question-option/dtos/question-option-response.dto.ts index 7b2df6c..3e742b9 100644 --- a/src/question-option/dtos/question-option-response.dto.ts +++ b/src/question-option/dtos/question-option-response.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from '@nestjs/swagger'; import { QuestionOption } from '../question-option.entity'; import { PaginatedResponse } from 'src/shared/pagination/dtos/paginate-response.dto'; -import { Question } from 'src/question/question.entity'; +import { QuestionResponseDto } from 'src/question/dtos/question-response.dto'; export class QuestionOptionResponseDto { @ApiProperty({ @@ -13,7 +13,7 @@ export class QuestionOptionResponseDto { @ApiProperty({ description: 'Question Data', - type: Question, + type: QuestionResponseDto, example: { id: 'e20ffe51-2514-4f14-9bea-4bb28bb97fdd', courseModuleId: '7093a5ae-cc1d-4017-8445-cba7ea978b22', @@ -29,7 +29,7 @@ export class QuestionOptionResponseDto { }, }, }) - question: Question; + question: QuestionResponseDto; @ApiProperty({ description: 'Question option text', diff --git a/src/question-option/question-option.controller.ts b/src/question-option/question-option.controller.ts index e97e4e0..be398bb 100644 --- a/src/question-option/question-option.controller.ts +++ b/src/question-option/question-option.controller.ts @@ -25,6 +25,7 @@ import { Roles } from 'src/shared/decorators/role.decorator'; import { Role } from 'src/shared/enums'; import { CreateQuestionOptionDto } from './dtos/create-question-option.dto'; import { UpdateQuestionOptionDto } from './dtos/update-question-option.dto'; +import { PaginatedQuestionOptionPretestResponseDto } from './dtos/question-option-pretest-response.dto'; @Controller('question-option') @Injectable() @@ -73,6 +74,48 @@ export class QuestionOptionController { ); } + @Get('/pretest') + @Roles(Role.STUDENT) + @Roles(Role.ADMIN) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Returns all question option pretest', + type: PaginatedQuestionOptionPretestResponseDto, + isArray: true, + }) + @ApiQuery({ + name: 'page', + type: Number, + required: false, + description: 'Page number', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Items per page', + }) + @ApiQuery({ + name: 'search', + type: String, + required: false, + description: 'Search by title', + }) + async findAllQuestionPretest( + @Req() request: AuthenticatedRequest, + @Query() query: PaginateQueryDto, + ): Promise { + return await this.questionOptionService.findAllQuestionOptionPretest( + request.user.id, + request.user.role, + { + page: query.page, + limit: query.limit, + search: query.search, + }, + ); + } + @Get(':id') @ApiResponse({ status: HttpStatus.OK, @@ -100,6 +143,57 @@ export class QuestionOptionController { return new QuestionOptionResponseDto(questionOption); } + @Get('pretest/:questionId') + @Roles(Role.STUDENT) + @Roles(Role.ADMIN) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Returns all question options in pretest id', + type: PaginatedQuestionOptionPretestResponseDto, + isArray: true, + }) + @ApiQuery({ + name: 'page', + type: Number, + required: false, + description: 'Page number', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Items per page', + }) + @ApiQuery({ + name: 'search', + type: String, + required: false, + description: 'Search by title', + }) + async findQuestionOptionPretestByQuestionId( + @Req() request: AuthenticatedRequest, + @Query() query: PaginateQueryDto, + @Param( + 'questionId', + new ParseUUIDPipe({ + version: '4', + errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }), + ) + questionId: string, + ): Promise { + return await this.questionOptionService.findQuestionOptionPretestByQuestionId( + request.user.id, + request.user.role, + questionId, + { + page: query.page, + limit: query.limit, + search: query.search, + }, + ); + } + @Get('question/:questionId') @ApiResponse({ status: HttpStatus.OK, diff --git a/src/question-option/question-option.service.ts b/src/question-option/question-option.service.ts index 514ed2c..9573f92 100644 --- a/src/question-option/question-option.service.ts +++ b/src/question-option/question-option.service.ts @@ -21,6 +21,7 @@ import { CourseStatus, ExamStatus, QuestionType, Role } from 'src/shared/enums'; import { CreateQuestionOptionDto } from './dtos/create-question-option.dto'; import { Question } from 'src/question/question.entity'; import { UpdateQuestionOptionDto } from './dtos/update-question-option.dto'; +import { PaginatedQuestionOptionPretestResponseDto } from './dtos/question-option-pretest-response.dto'; @Injectable() export class QuestionOptionService { constructor( @@ -130,7 +131,10 @@ export class QuestionOptionService { role, search, ); - whereCondition['question'] = { id: questionId }; + whereCondition['question'] = { + id: questionId, + type: Not(QuestionType.PRETEST), + }; const question = await this.questionRepository.findOne({ where: { id: questionId }, @@ -252,6 +256,28 @@ export class QuestionOptionService { return questionOption; } + async createQuestionOptionPretest( + createQuestionOptionDto: CreateQuestionOptionDto, + ): Promise { + const question = await this.questionRepository.findOne({ + where: { id: createQuestionOptionDto.questionId }, + select: this.selectPopulateQuestion(), + relations: ['pretest', 'pretest.user'], + }); + if (!question) { + throw new NotFoundException('Question option not found.'); + } + const questionOption = await this.questionOptionRepository.create({ + ...createQuestionOptionDto, + question, + }); + if (!questionOption) { + throw new BadRequestException('Question option not create.'); + } + await this.questionOptionRepository.save(questionOption); + return questionOption; + } + async updateQuestionOption( userId: string, role: Role, @@ -341,4 +367,144 @@ export class QuestionOptionService { return false; } } + + async findAllQuestionOptionPretest( + userId: string, + role: Role, + { + page = 1, + limit = 20, + search = '', + }: { + page?: number; + limit?: number; + search?: string; + }, + ): Promise { + const { find } = await createPagination(this.questionOptionRepository, { + page, + limit, + }); + + const whereCondition = this.validateAndCreateConditionForPretest( + userId, + role, + search, + ); + const question = await find({ + where: whereCondition, + relations: ['question', 'question.pretest', 'question.pretest.user'], + select: { + question: this.selectPopulateQuestionForPretest(), + }, + }).run(); + + return question; + } + + async findQuestionOptionPretestByQuestionId( + userId: string, + role: Role, + questionId: string, + { + page = 1, + limit = 20, + search = '', + }: { + page?: number; + limit?: number; + search?: string; + }, + ): Promise { + const { find } = await createPagination(this.questionOptionRepository, { + page, + limit, + }); + + const whereCondition = this.validateAndCreateConditionForPretest( + userId, + role, + search, + ); + whereCondition['question'] = { id: questionId, type: QuestionType.PRETEST }; + + const question = await this.questionRepository.findOne({ + where: { id: questionId }, + }); + + if (!question) { + throw new NotFoundException('Question not found.'); + } + + const questionOption = await find({ + where: whereCondition, + relations: ['question', 'question.pretest', 'question.pretest.user'], + select: { + question: this.selectPopulateQuestionForPretest(), + }, + }).run(); + return questionOption; + } + + private validateAndCreateConditionForPretest( + userId: string, + role: Role, + search: string, + ): FindOptionsWhere { + const baseSearch = search ? { optionText: ILike(`%${search}%`) } : {}; + + if (role === Role.STUDENT) { + return { + ...baseSearch, + question: { + pretest: { + user: { + id: userId, + }, + }, + }, + }; + } + + if (role === Role.ADMIN) { + return { ...baseSearch }; + } + + return { + ...baseSearch, + question: { + pretest: { + user: { + id: userId, + }, + }, + }, + }; + } + + private selectPopulateQuestionForPretest(): FindOptionsSelect { + return { + id: true, + question: true, + type: true, + points: true, + orderIndex: true, + pretest: { + id: true, + timeLimit: true, + title: true, + description: true, + passingScore: true, + maxAttempts: true, + user: { + id: true, + username: true, + fullname: true, + role: true, + email: true, + profileKey: true, + }, + }, + }; + } } diff --git a/src/question/dtos/create-question.dto.ts b/src/question/dtos/create-question.dto.ts index 552a002..125aae6 100644 --- a/src/question/dtos/create-question.dto.ts +++ b/src/question/dtos/create-question.dto.ts @@ -36,7 +36,55 @@ export class CreateQuestionDto { type: Number, example: 1, }) - points: number = 1; + points: number; + + @IsOptional() + @IsInt() + @Min(1) + @ApiProperty({ + description: 'Order question', + type: Number, + example: 1, + }) + orderIndex?: number; +} + +export class CreateQuestionPretestDto { + @IsNotEmpty() + @ApiProperty({ + description: 'Pretest ID', + type: String, + example: '8d4887aa-28e7-4d0e-844c-28a8ccead003', + }) + pretestId: string; + + @IsNotEmpty() + @ApiProperty({ + description: 'Question exam', + type: String, + example: 'What is this?', + }) + question: string; + + @IsNotEmpty() + @IsEnum(QuestionType) + @ApiProperty({ + description: 'Type question', + type: String, + example: QuestionType.PRETEST, + enum: QuestionType, + }) + type: QuestionType; + + @IsNotEmpty() + @IsInt() + @Min(1) + @ApiProperty({ + description: 'Points in 1 question', + type: Number, + example: 1, + }) + points: number; @IsOptional() @IsInt() diff --git a/src/question/dtos/question-pretest-response.dto.ts b/src/question/dtos/question-pretest-response.dto.ts new file mode 100644 index 0000000..6ae6aa2 --- /dev/null +++ b/src/question/dtos/question-pretest-response.dto.ts @@ -0,0 +1,107 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaginatedResponse } from 'src/shared/pagination/dtos/paginate-response.dto'; +import { Question } from '../question.entity'; +import { QuestionType } from 'src/shared/enums'; +import { Pretest } from 'src/pretest/pretest.entity'; +import { PretestResponseDto } from 'src/pretest/dtos/pretest-response.dto'; + +export class QuestionPretestResponseDto { + @ApiProperty({ + description: 'Question ID', + type: String, + example: '123e4567-e89b-12d3-a456-426614174000', + }) + id: string; + + @ApiProperty({ + description: 'Pretest Data', + type: PretestResponseDto, + example: { + id: '80ff2ec3-7c6d-4427-a98d-58ac3aa68697', + user: { + id: 'a12e1e37-3504-4711-a389-09a1734d7b1c', + username: 'johndoe', + fullname: 'John Doe', + role: 'student', + email: 'johndoe@gmail.com', + profileKey: null, + }, + title: 'Biology', + description: 'This course is an introduction to biology', + timeLimit: 20, + passingScore: 3, + maxAttempts: 1, + }, + }) + pretest: Pretest; + + @ApiProperty({ + description: 'Pretest question', + type: String, + example: 'What is this?', + }) + question: string; + + @ApiProperty({ + description: 'Type question pretest', + type: String, + example: QuestionType.PRETEST, + enum: QuestionType, + }) + type: QuestionType; + + @ApiProperty({ + description: 'Points in 1 question', + type: Number, + example: 0, + }) + points: Number; + + @ApiProperty({ + description: 'Index in exam.', + type: Number, + example: 1, + }) + orderIndex: Number; + + @ApiProperty({ + description: 'Exam created date', + type: Date, + example: new Date(), + }) + createdAt: Date; + + @ApiProperty({ + description: 'Exam updated date', + type: Date, + example: new Date(), + }) + updatedAt: Date; + + constructor(question: Question) { + this.id = question.id; + this.pretest = question.pretest; + this.question = question.question; + this.type = question.type; + this.points = question.points; + this.orderIndex = question.orderIndex; + this.createdAt = question.createdAt; + this.updatedAt = question.updatedAt; + } +} + +export class PaginatedQuestionPretestResponseDto extends PaginatedResponse( + QuestionPretestResponseDto, +) { + constructor( + question: Question[], + total: number, + pageSize: number, + currentPage: number, + ) { + const questionDtos = question.map( + (question) => new QuestionPretestResponseDto(question), + ); + super(questionDtos, total, pageSize, currentPage); + } +} diff --git a/src/question/dtos/question-response.dto.ts b/src/question/dtos/question-response.dto.ts index 407ae81..d79fd9e 100644 --- a/src/question/dtos/question-response.dto.ts +++ b/src/question/dtos/question-response.dto.ts @@ -61,7 +61,7 @@ export class QuestionResponseDto { points: Number; @ApiProperty({ - description: 'Score to pass exam.', + description: 'Index in exam.', type: Number, example: 1, }) diff --git a/src/question/dtos/update-question.dto.ts b/src/question/dtos/update-question.dto.ts index 94c4a43..41ee21f 100644 --- a/src/question/dtos/update-question.dto.ts +++ b/src/question/dtos/update-question.dto.ts @@ -1,6 +1,13 @@ import { OmitType, PartialType } from '@nestjs/swagger'; -import { CreateQuestionDto } from './create-question.dto'; +import { + CreateQuestionDto, + CreateQuestionPretestDto, +} from './create-question.dto'; export class UpdateQuestionDto extends PartialType( OmitType(CreateQuestionDto, ['examId'] as const), ) {} + +export class UpdateQuestionPretestDto extends PartialType( + OmitType(CreateQuestionPretestDto, ['pretestId'] as const), +) {} diff --git a/src/question/question.controller.ts b/src/question/question.controller.ts index 1569403..fdc93a3 100644 --- a/src/question/question.controller.ts +++ b/src/question/question.controller.ts @@ -25,6 +25,10 @@ import { Roles } from 'src/shared/decorators/role.decorator'; import { Role } from 'src/shared/enums'; import { CreateQuestionDto } from './dtos/create-question.dto'; import { UpdateQuestionDto } from './dtos/update-question.dto'; +import { + PaginatedQuestionPretestResponseDto, + QuestionPretestResponseDto, +} from './dtos/question-pretest-response.dto'; @Controller('question') @Injectable() @@ -73,6 +77,48 @@ export class QuestionController { ); } + @Get('/pretest') + @Roles(Role.STUDENT) + @Roles(Role.TEACHER) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Returns all questions pretest', + type: PaginatedQuestionPretestResponseDto, + isArray: true, + }) + @ApiQuery({ + name: 'page', + type: Number, + required: false, + description: 'Page number', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Items per page', + }) + @ApiQuery({ + name: 'search', + type: String, + required: false, + description: 'Search by title', + }) + async findAllQuestionPretest( + @Req() request: AuthenticatedRequest, + @Query() query: PaginateQueryDto, + ): Promise { + return await this.questionService.findAllQuestionPretest( + request.user.id, + request.user.role, + { + page: query.page, + limit: query.limit, + search: query.search, + }, + ); + } + @Get(':id') @ApiResponse({ status: HttpStatus.OK, @@ -100,6 +146,58 @@ export class QuestionController { return new QuestionResponseDto(question); } + @Get('pretest/:pretestId') + @Roles(Role.STUDENT) + @Roles(Role.TEACHER) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Returns all questions pretest with pretest id', + type: PaginatedQuestionPretestResponseDto, + isArray: true, + }) + @ApiQuery({ + name: 'page', + type: Number, + required: false, + description: 'Page number', + }) + @ApiQuery({ + name: 'limit', + type: Number, + required: false, + description: 'Items per page', + }) + @ApiQuery({ + name: 'search', + type: String, + required: false, + description: 'Search by title', + }) + async findQuestionPretestByPretestId( + @Req() request: AuthenticatedRequest, + @Query() query: PaginateQueryDto, + @Param( + 'pretestId', + new ParseUUIDPipe({ + version: '4', + errorHttpStatusCode: HttpStatus.UNPROCESSABLE_ENTITY, + }), + ) + pretestId: string, + ): Promise { + const question = await this.questionService.findQuestionPretestByPretestId( + request.user.id, + request.user.role, + pretestId, + { + page: query.page, + limit: query.limit, + search: query.search, + }, + ); + return question; + } + @Get('exam/:examId') @ApiResponse({ status: HttpStatus.OK, diff --git a/src/question/question.entity.ts b/src/question/question.entity.ts index b7cdbb1..0dac85e 100644 --- a/src/question/question.entity.ts +++ b/src/question/question.entity.ts @@ -1,5 +1,6 @@ import { ExamAnswer } from 'src/exam-answer/exam-answer.entity'; import { Exam } from 'src/exam/exam.entity'; +import { Pretest } from 'src/pretest/pretest.entity'; import { QuestionOption } from 'src/question-option/question-option.entity'; import { QuestionType } from 'src/shared/enums'; import { @@ -21,14 +22,24 @@ export class Question { @ManyToOne(() => Exam, (exam) => exam.question, { onDelete: 'CASCADE', - nullable: false, + nullable: true, }) @JoinColumn({ name: 'exam_id' }) exam: Exam; - @Column({ name: 'exam_id' }) + @Column({ name: 'exam_id', nullable: true }) examId: string; + @ManyToOne(() => Pretest, (pretest) => pretest.question, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'pretest_id' }) + pretest: Pretest; + + @Column({ name: 'pretest_id', nullable: true }) + pretestId: string; + @OneToMany( () => QuestionOption, (questionOption) => questionOption.question, @@ -57,13 +68,13 @@ export class Question { nullable: false, default: 1, }) - points: number; + points: number = 1; @Column({ nullable: false, default: 1, }) - orderIndex: number; + orderIndex: number = 1; @CreateDateColumn({ type: 'timestamp with time zone', diff --git a/src/question/question.module.ts b/src/question/question.module.ts index a68ce4f..0e362f1 100644 --- a/src/question/question.module.ts +++ b/src/question/question.module.ts @@ -6,9 +6,13 @@ import { questionProviders } from './question.providers'; import { QuestionController } from './question.controller'; import { QuestionService } from './question.service'; import { Exam } from 'src/exam/exam.entity'; +import { Pretest } from 'src/pretest/pretest.entity'; @Module({ - imports: [DatabaseModule, TypeOrmModule.forFeature([Question, Exam])], + imports: [ + DatabaseModule, + TypeOrmModule.forFeature([Question, Exam, Pretest]), + ], controllers: [QuestionController], providers: [...questionProviders, QuestionService], exports: [QuestionService], diff --git a/src/question/question.service.ts b/src/question/question.service.ts index dcc8a03..729be92 100644 --- a/src/question/question.service.ts +++ b/src/question/question.service.ts @@ -16,11 +16,15 @@ import { } from 'typeorm'; import { PaginatedQuestionResponseDto } from './dtos/question-response.dto'; import { createPagination } from 'src/shared/pagination'; -import { AuthenticatedRequest } from 'src/auth/interfaces/authenticated-request.interface'; import { CourseStatus, ExamStatus, QuestionType, Role } from 'src/shared/enums'; -import { CreateQuestionDto } from './dtos/create-question.dto'; +import { + CreateQuestionDto, + CreateQuestionPretestDto, +} from './dtos/create-question.dto'; import { UpdateQuestionDto } from './dtos/update-question.dto'; import { Exam } from 'src/exam/exam.entity'; +import { Pretest } from 'src/pretest/pretest.entity'; +import { PaginatedQuestionPretestResponseDto } from './dtos/question-pretest-response.dto'; @Injectable() export class QuestionService { constructor( @@ -28,6 +32,8 @@ export class QuestionService { private readonly questionRepository: Repository, @Inject('ExamRepository') private readonly examRepository: Repository, + @Inject('PretestRepository') + private readonly pretestRepository: Repository, ) {} async findAll( @@ -386,6 +392,162 @@ export class QuestionService { }; } + async findAllQuestionPretest( + userId: string, + role: Role, + { + page = 1, + limit = 20, + search = '', + }: { + page?: number; + limit?: number; + search?: string; + }, + ): Promise { + const { find } = await createPagination(this.questionRepository, { + page, + limit, + }); + + const whereCondition = this.validateAndCreateConditionForPretest( + userId, + role, + search, + ); + const question = await find({ + order: { + orderIndex: 'ASC', + }, + where: whereCondition, + relations: ['pretest', 'pretest.user'], + select: { + pretest: this.selectPopulatePretest(), + }, + }).run(); + + return question; + } + + async findQuestionPretestByPretestId( + userId: string, + role: Role, + pretestId: string, + { + page = 1, + limit = 20, + search = '', + }: { + page?: number; + limit?: number; + search?: string; + }, + ): Promise { + const { find } = await createPagination(this.questionRepository, { + page, + limit, + }); + + const whereCondition = this.validateAndCreateConditionForPretest( + userId, + role, + search, + ); + whereCondition['pretest'] = { id: pretestId }; + + const pretest = await this.pretestRepository.findOne({ + where: { id: pretestId }, + }); + + if (!pretest) { + throw new NotFoundException('Pretest not found.'); + } + + const question = await find({ + order: { + orderIndex: 'ASC', + }, + where: whereCondition, + relations: ['pretest', 'pretest.user'], + select: { + pretest: this.selectPopulatePretest(), + }, + }).run(); + return question; + } + + private validateAndCreateConditionForPretest( + userId: string, + role: Role, + search: string, + ): FindOptionsWhere { + const baseSearch = search ? { question: ILike(`%${search}%`) } : {}; + + if (role === Role.STUDENT) { + return { + ...baseSearch, + pretest: { + user: { + id: userId, + }, + }, + }; + } + + if (role === Role.ADMIN) { + return { ...baseSearch }; + } + + return { + ...baseSearch, + pretest: { + user: { + id: userId, + }, + }, + }; + } + + async createQuestionForPretest( + createQuestionPretestDto: CreateQuestionPretestDto, + ): Promise { + const pretest = await this.pretestRepository.findOne({ + where: { id: createQuestionPretestDto.pretestId }, + }); + + if (!pretest) throw new NotFoundException('Not found pretest'); + + const question = this.questionRepository.create({ + ...createQuestionPretestDto, + pretest, + }); + + if (!question) + throw new BadRequestException('Cannot create pretest question'); + + await this.questionRepository.save(question); + return question; + } + + private selectPopulatePretest(): FindOptionsSelect { + return { + id: true, + title: true, + description: true, + timeLimit: true, + passingScore: true, + maxAttempts: true, + user: { + id: true, + username: true, + fullname: true, + role: true, + email: true, + profileKey: true, + }, + }; + } + private checkPermission( userId: string, role: Role, diff --git a/src/shared/configs/database.config.ts b/src/shared/configs/database.config.ts index 68004b2..f20ba7b 100644 --- a/src/shared/configs/database.config.ts +++ b/src/shared/configs/database.config.ts @@ -23,6 +23,7 @@ import { User } from 'src/user/user.entity'; import { UserReward } from 'src/user-reward/user-reward.entity'; import { DataSource, DataSourceOptions } from 'typeorm'; import { GLOBAL_CONFIG } from '../constants/global-config.constant'; +import { Pretest } from 'src/pretest/pretest.entity'; const configService = new ConfigService(); @@ -56,6 +57,7 @@ export const databaseConfig: DataSourceOptions = { ChatRoom, ChatMessage, Roadmap, + Pretest, ], }; diff --git a/src/shared/enums/question-type.enum.ts b/src/shared/enums/question-type.enum.ts index f657d20..b5ce356 100644 --- a/src/shared/enums/question-type.enum.ts +++ b/src/shared/enums/question-type.enum.ts @@ -2,4 +2,5 @@ export enum QuestionType { MULTIPLE_CHOICE = 'multiple_choice', TRUE_FALSE = 'true_false', ESSAY = 'essay', + PRETEST = 'pretest', } diff --git a/src/user-background/user-background.service.ts b/src/user-background/user-background.service.ts index 21e0df3..335e3f3 100644 --- a/src/user-background/user-background.service.ts +++ b/src/user-background/user-background.service.ts @@ -79,8 +79,9 @@ export class UserBackgroundService { occupationId: data.occupationId, }); - const savedUserBackground = - await this.userBackgroundRepository.save(userBackground); + const savedUserBackground = await this.userBackgroundRepository.save( + userBackground, + ); return savedUserBackground; } @@ -98,8 +99,9 @@ export class UserBackgroundService { }; this.userBackgroundRepository.merge(userBackground, updateData); - const savedUserBackground = - await this.userBackgroundRepository.save(userBackground); + const savedUserBackground = await this.userBackgroundRepository.save( + userBackground, + ); return savedUserBackground; } @@ -113,4 +115,18 @@ export class UserBackgroundService { return userBackground; } + + async findOneByUserId(userId: string): Promise { + const userBackground = this.userBackgroundRepository.findOne({ + where: { user: { id: userId } }, + order: { updatedAt: 'DESC' }, + relations: { + user: true, + topics: true, + occupation: true, + }, + }); + if (!userBackground) throw new NotFoundException('Not found this user'); + return userBackground; + } } diff --git a/src/user/user.entity.ts b/src/user/user.entity.ts index d011044..8b929cf 100644 --- a/src/user/user.entity.ts +++ b/src/user/user.entity.ts @@ -14,6 +14,7 @@ import { PrimaryGeneratedColumn, UpdateDateColumn, } from 'typeorm'; +import { Pretest } from 'src/pretest/pretest.entity'; @Entity() export class User { @@ -76,7 +77,13 @@ export class User { }) enrollments: Enrollment[]; - @OneToMany(() => ExamAttempt, (examAttempt) => examAttempt.exam, { + @OneToMany(() => Pretest, (pretest) => pretest.user, { + cascade: true, + nullable: true, + }) + pretest: Pretest[]; + + @OneToMany(() => ExamAttempt, (examAttempt) => examAttempt.user, { cascade: true, nullable: true, })