diff --git a/src/common/modules/redis/providers/redis.service.ts b/src/common/modules/redis/providers/redis.service.ts index 6879869..f7f37c9 100644 --- a/src/common/modules/redis/providers/redis.service.ts +++ b/src/common/modules/redis/providers/redis.service.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-unsafe-call */ import { Inject, Injectable } from '@nestjs/common'; import { REDIS } from '../constants/redis.constants'; import Redis from 'ioredis'; @@ -17,16 +16,22 @@ export class RedisService { return this.redis; } - async set(key: string, val: string, ttl?: number) { + async set(key: string, val: T, ttl?: number) { + const payload = JSON.stringify(val); + if (ttl) { - await this.redis.set(key, val, 'EX', ttl); + await this.redis.set(key, payload, 'EX', ttl); } else { - await this.redis.set(key, val); + await this.redis.set(key, payload); } } - async get(key: string) { - return await this.redis.get(key); + async get(key: string): Promise { + const value = await this.redis.get(key); + + if (!value) return null; + + return JSON.parse(value) as T; } async del(key: string) { diff --git a/src/i18n/en/scoring.json b/src/i18n/en/scoring.json new file mode 100644 index 0000000..1feb612 --- /dev/null +++ b/src/i18n/en/scoring.json @@ -0,0 +1,9 @@ +{ + "fields": { + "resultRange": "Result Range" + }, + "errors": { + "resultRangeOverlappingConstraint": "The specified score range has some overlaps with other records.", + "resultRangeMinMaxConstraint": "minScore value should be less than maxScore." + } +} diff --git a/src/i18n/en/submissions.json b/src/i18n/en/submissions.json new file mode 100644 index 0000000..350dbd3 --- /dev/null +++ b/src/i18n/en/submissions.json @@ -0,0 +1,16 @@ +{ + "fields": { + "testId": "Test's Id", + "questionId": "Question's Id", + "submission": "Submission", + "submissionId": "Submission's Id", + "choiceId": "Choice's Id" + }, + "errors": { + "alreadyTookTest": "You already took this test.", + "choiceNumericConstraint": "You must send either choiceId or numericValue.", + "emptyNumericValue": "A numeric value should be provided as answer.", + "numericValueRange": "Numeric value is out of specified range.", + "emptyChoiceId": "Choice Id should be provided as answer." + } +} diff --git a/src/i18n/fa/scoring.json b/src/i18n/fa/scoring.json new file mode 100644 index 0000000..45dabff --- /dev/null +++ b/src/i18n/fa/scoring.json @@ -0,0 +1,9 @@ +{ + "fields": { + "resultRange": "بازه نتیجه" + }, + "errors": { + "resultRangeOverlappingConstraint": "بازه امتیاز مشخص شده با بازه های دیگر تداخل دارد.", + "resultRangeMinMaxConstraint": "minScore باید کمتر از maxScore باشد." + } +} diff --git a/src/i18n/fa/submissions.json b/src/i18n/fa/submissions.json new file mode 100644 index 0000000..d24c15a --- /dev/null +++ b/src/i18n/fa/submissions.json @@ -0,0 +1,16 @@ +{ + "fields": { + "testId": "شناسه آزمون", + "questionId": "شناسه سوال", + "submission": "Submission", + "submissionId": "شناسه Submission", + "choiceId": "شناسه گزینه" + }, + "errors": { + "alreadyTookTest": "قبلا این آزمون را انجام داده اید.", + "choiceNumericConstraint": "حتما باید یکی از فیلد های شناسه انتخاب و یا مقدار عددی فرستاده شود.", + "emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.", + "numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.", + "emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود." + } +} diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index e064307..8ee0233 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -9,6 +9,8 @@ import { AdminAuthService } from './auth/providers/admin-auth.service'; import { QuestionsModule } from '../questions/questions.module'; import { AdminQuestionsController } from './questions/admin-questions.controller'; import { AdminChoicesController } from './choices/admin-choices.controller'; +import { ScoringModule } from '../scoring/scoring.module'; +import { AdminScoringController } from './scoring/admin-scoring.controller'; @Module({ imports: [ @@ -17,12 +19,14 @@ import { AdminChoicesController } from './choices/admin-choices.controller'; AuthModule, HashingModule, UsersModule, + ScoringModule, ], controllers: [ AdminTestsController, AdminAuthController, AdminQuestionsController, AdminChoicesController, + AdminScoringController, ], providers: [AdminAuthService], }) diff --git a/src/modules/admin/choices/http/admin-choices-get-endpoints.http b/src/modules/admin/choices/http/admin-choices-get-endpoints.http index 1685f48..c26353a 100644 --- a/src/modules/admin/choices/http/admin-choices-get-endpoints.http +++ b/src/modules/admin/choices/http/admin-choices-get-endpoints.http @@ -1,5 +1,5 @@ GET {{baseUrl}}/admin/choices/ Authorization: Bearer {{$global.accessToken}} -GET {{baseUrl}}/admin/choices/1 +GET {{baseUrl}}/admin/choices/3 Authorization: Bearer {{$global.accessToken}} diff --git a/src/modules/admin/choices/http/admin-choices.patch.endpoints.http b/src/modules/admin/choices/http/admin-choices.patch.endpoints.http index a1ad3b4..97b7c8d 100644 --- a/src/modules/admin/choices/http/admin-choices.patch.endpoints.http +++ b/src/modules/admin/choices/http/admin-choices.patch.endpoints.http @@ -1,7 +1,8 @@ -PATCH {{baseUrl}}/admin/choices/1 +PATCH {{baseUrl}}/admin/choices/3 Authorization: Bearer {{$global.accessToken}} Content-Type: application/json { - "question": 4 + "text": "Option 1", + "order": 1 } diff --git a/src/modules/admin/choices/http/admin-choices.post.endpoints.http b/src/modules/admin/choices/http/admin-choices.post.endpoints.http index ce46a86..e66f6b3 100644 --- a/src/modules/admin/choices/http/admin-choices.post.endpoints.http +++ b/src/modules/admin/choices/http/admin-choices.post.endpoints.http @@ -5,6 +5,7 @@ Content-Type: application/json { "question": 4, "text": "Option 2", - "value": 2, - "order": 2 + "value": 1, + "order": 2, + "score": 2 } \ No newline at end of file diff --git a/src/modules/admin/questions/http/admin-questions.get.endpoints.http b/src/modules/admin/questions/http/admin-questions.get.endpoints.http index 199081e..1949cb4 100644 --- a/src/modules/admin/questions/http/admin-questions.get.endpoints.http +++ b/src/modules/admin/questions/http/admin-questions.get.endpoints.http @@ -1,7 +1,7 @@ GET {{baseUrl}}/admin/questions Authorization: Bearer {{$global.accessToken}} -GET {{baseUrl}}/admin/questions/1 +GET {{baseUrl}}/admin/questions/4 Authorization: Bearer {{$global.accessToken}} GET {{baseUrl}}/admin/questions/range-rules?limit=1&page=2&sortBy=id&order=ASC diff --git a/src/modules/admin/questions/http/admin-questions.patch.endpoints.http b/src/modules/admin/questions/http/admin-questions.patch.endpoints.http index a7ed8ea..122cdfd 100644 --- a/src/modules/admin/questions/http/admin-questions.patch.endpoints.http +++ b/src/modules/admin/questions/http/admin-questions.patch.endpoints.http @@ -1,9 +1,9 @@ -PATCH {{baseUrl}}/admin/questions/4 +PATCH {{baseUrl}}/admin/questions/1 Authorization: Bearer {{$global.accessToken}} Content-Type: application/json { - "test": 4 + "type": "multi-choice" } PATCH {{baseUrl}}/admin/questions/range-rules/1 diff --git a/src/modules/admin/scoring/admin-scoring.controller.ts b/src/modules/admin/scoring/admin-scoring.controller.ts new file mode 100644 index 0000000..c176cf2 --- /dev/null +++ b/src/modules/admin/scoring/admin-scoring.controller.ts @@ -0,0 +1,58 @@ +import { Role } from '@/common/enums/roles.enum'; +import { Roles } from '@/modules/auth/decorators/roles.decorator'; +import { CreateResultRangeDTO } from '@/modules/scoring/dtos/create-result-range.dto'; +import { FindResultRangeDTO } from '@/modules/scoring/dtos/find-result-range.dto'; +import { UpdateResultRangeDTO } from '@/modules/scoring/dtos/update-result-range.dto'; +import { ResultRangeService } from '@/modules/scoring/providers/result-range.service'; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; + +@Controller('admin/scoring') +@Roles(Role.SUPERUSER, Role.ADMIN) +export class AdminScoringController { + constructor( + /** + * Inject ResultRange Service + */ + private readonly resultRangeService: ResultRangeService, + ) {} + + @Post('result-ranges') + public async createResultRanges( + @Body() createResultRangeDto: CreateResultRangeDTO, + ) { + return await this.resultRangeService.createOne(createResultRangeDto); + } + + @Get('result-ranges') + public async getAllResultRanges(@Query() query: FindResultRangeDTO) { + return await this.resultRangeService.findAllForAdmin(query); + } + + @Get('result-ranges/:id') + public async getResultRangeById(@Param('id', ParseIntPipe) id: number) { + return await this.resultRangeService.findOneForAdmin(id); + } + + @Patch('result-ranges/:id') + public async updateResultRange( + @Param('id', ParseIntPipe) id: number, + @Body() updateResultRangeDto: UpdateResultRangeDTO, + ) { + return await this.resultRangeService.updateOne(id, updateResultRangeDto); + } + + @Delete('result-ranges/:id') + public async deleteResultRange(@Param('id', ParseIntPipe) id: number) { + return await this.resultRangeService.deleteOne(id); + } +} diff --git a/src/modules/admin/scoring/http/admin-scoring.get.endpoints.http b/src/modules/admin/scoring/http/admin-scoring.get.endpoints.http new file mode 100644 index 0000000..aa080f4 --- /dev/null +++ b/src/modules/admin/scoring/http/admin-scoring.get.endpoints.http @@ -0,0 +1,2 @@ +GET {{baseUrl}}/admin/scoring/result-ranges/ +Authorization: Bearer {{$global.accessToken}} diff --git a/src/modules/admin/scoring/http/admin-scoring.post.endpoints.http b/src/modules/admin/scoring/http/admin-scoring.post.endpoints.http new file mode 100644 index 0000000..f31fceb --- /dev/null +++ b/src/modules/admin/scoring/http/admin-scoring.post.endpoints.http @@ -0,0 +1,11 @@ +POST {{baseUrl}}/admin/scoring/result-ranges/ +Authorization: Bearer {{$global.accessToken}} +Content-Type: application/json + +{ + "test": 4, + "title": "احتمال خیلی بالا", + "description": "احتمال ابتلا خیلی بالا است.", + "minScore": 21, + "maxScore": 40 +} \ No newline at end of file diff --git a/src/modules/auth/guards/optional-jwt.guard.ts b/src/modules/auth/guards/optional-jwt.guard.ts new file mode 100644 index 0000000..1639201 --- /dev/null +++ b/src/modules/auth/guards/optional-jwt.guard.ts @@ -0,0 +1,10 @@ +import { User } from '@/modules/users/entities/user.entity'; +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class OptionalJwtGuard extends AuthGuard('jwt') { + handleRequest(err: any, user: TUser): TUser | null { + return user ?? null; + } +} diff --git a/src/modules/auth/http/login.post.endpoints.http b/src/modules/auth/http/login.post.endpoints.http index ecc237a..e91fb4a 100644 --- a/src/modules/auth/http/login.post.endpoints.http +++ b/src/modules/auth/http/login.post.endpoints.http @@ -3,7 +3,7 @@ Content-Type: application/json lang: fa { - "phone": "09121111111", + "phone": "09333026363", "password": "Password1@" } diff --git a/src/modules/otp/providers/otp.service.ts b/src/modules/otp/providers/otp.service.ts index 12eaedb..d155a0a 100644 --- a/src/modules/otp/providers/otp.service.ts +++ b/src/modules/otp/providers/otp.service.ts @@ -2,7 +2,7 @@ import { HttpStatus, Injectable, UnauthorizedException } from '@nestjs/common'; import { LessThan, MoreThanOrEqual, Repository } from 'typeorm'; import { OTP } from '../entities/otp.entity'; import { InjectRepository } from '@nestjs/typeorm'; -import { Cron } from '@nestjs/schedule'; +import { Cron, CronExpression } from '@nestjs/schedule'; import { HashingProvider } from '@/common/modules/hashing/providers/hashing.provider'; import { AppException } from '@/common/exceptions/app.exception'; import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; @@ -86,7 +86,7 @@ export class OtpService { return otp; } - @Cron('0 */10 * * * *') + @Cron(CronExpression.EVERY_10_MINUTES) public async cleanupExpired(): Promise { await this.otpRepository.delete({ expiresAt: LessThan(new Date()), diff --git a/src/modules/questions/entities/question.entity.ts b/src/modules/questions/entities/question.entity.ts index 27583b3..63fd917 100644 --- a/src/modules/questions/entities/question.entity.ts +++ b/src/modules/questions/entities/question.entity.ts @@ -36,6 +36,7 @@ export class Question { content!: string; @Column({ type: 'int', nullable: false }) + @Index() order!: number; @Column({ type: 'int', default: 1 }) @@ -51,7 +52,7 @@ export class Question { metadata!: QuestionMetadata; @OneToMany(() => Choice, (choice) => choice.question) - choices?: Array; + choices!: Array; @CreateDateColumn() createdAt!: Date; diff --git a/src/modules/questions/providers/questions-command.provider.ts b/src/modules/questions/providers/questions-command.provider.ts index 6ce31a6..db74b6c 100644 --- a/src/modules/questions/providers/questions-command.provider.ts +++ b/src/modules/questions/providers/questions-command.provider.ts @@ -35,14 +35,6 @@ export class QuestionsCommandProvider { public async createOne(createQuestionDto: CreateQuestionDTO) { const test = await this.testsService.adminGetOne(createQuestionDto.test); - if (!test) { - throw new AppException( - 'common.errors.entityNotFound', - HttpStatus.NOT_FOUND, - { entity: '$t(tests.fields.test)' }, - ); - } - const newQuestion = this.questionsRepository.create({ ...createQuestionDto, test, @@ -81,15 +73,7 @@ export class QuestionsCommandProvider { } if (updateQuestionDto.test) { - const test = await this.testsService.adminGetOne(updateQuestionDto.test); - - if (!test) { - throw new AppException( - 'common.errors.entityNotFound', - HttpStatus.NOT_FOUND, - { entity: '$t(tests.fields.test)' }, - ); - } + await this.testsService.adminGetOne(updateQuestionDto.test); } const type = diff --git a/src/modules/questions/providers/questions-query.provider.ts b/src/modules/questions/providers/questions-query.provider.ts index 85d32f3..d93b727 100644 --- a/src/modules/questions/providers/questions-query.provider.ts +++ b/src/modules/questions/providers/questions-query.provider.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { filterQuestions } from '../helpers/filter-questions.helper'; import Utils from '@/common/utils'; import { FindQuestionsDTO } from '../dtos/find-questions.dto'; @@ -9,6 +9,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Question } from '../entities/question.entity'; import { Repository } from 'typeorm'; import { TestsService } from '@/modules/tests/providers/tests.service'; +import { AppException } from '@/common/exceptions/app.exception'; @Injectable() export class QuestionsQueryProvider { @@ -33,13 +34,13 @@ export class QuestionsQueryProvider { let selectedTest: Test | null = null; if (query.test) { - try { - selectedTest = await this.testsService.adminGetOne(query.test); - } catch (err) { - throw new AppTimeoutException(err); - } + selectedTest = await this.testsService.adminGetOne(query.test, { + relations: { questions: true }, + }); } + console.log(selectedTest); + try { const [questions, total] = await this.questionsRepository.findAndCount({ where: { @@ -69,13 +70,25 @@ export class QuestionsQueryProvider { } public async findOneForAdmin(id: number) { + let question: Question | null = null; + try { - return await this.questionsRepository.findOne({ + question = await this.questionsRepository.findOne({ where: { id }, - relations: { test: true }, + relations: { test: true, rangeRules: true, choices: true }, }); } catch (err) { throw new AppTimeoutException(err); } + + if (!question) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(questions.fields.question)' }, + ); + } + + return question; } } diff --git a/src/modules/scoring/decorators/is-valid-range.decorator.ts b/src/modules/scoring/decorators/is-valid-range.decorator.ts new file mode 100644 index 0000000..a72cfbb --- /dev/null +++ b/src/modules/scoring/decorators/is-valid-range.decorator.ts @@ -0,0 +1,6 @@ +import { Validate } from 'class-validator'; +import { ResultRangeValidator } from '../validators/result-range.validator'; + +export const IsValidRange = () => { + return Validate(ResultRangeValidator); +}; diff --git a/src/modules/scoring/dtos/create-result-range.dto.ts b/src/modules/scoring/dtos/create-result-range.dto.ts new file mode 100644 index 0000000..34269dd --- /dev/null +++ b/src/modules/scoring/dtos/create-result-range.dto.ts @@ -0,0 +1,27 @@ +import { IsNotEmpty, IsNumber, IsString } from 'class-validator'; +import { IsValidRange } from '../decorators/is-valid-range.decorator'; + +export class CreateResultRangeDTO { + @IsNumber() + @IsNotEmpty() + test!: number; + + @IsNumber() + @IsNotEmpty() + minScore!: number; + + @IsNumber() + @IsNotEmpty() + maxScore!: number; + + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + description!: string; + + @IsValidRange() + private readonly _resultRangeValidation!: never; +} diff --git a/src/modules/scoring/dtos/find-result-range.dto.ts b/src/modules/scoring/dtos/find-result-range.dto.ts new file mode 100644 index 0000000..f8953e1 --- /dev/null +++ b/src/modules/scoring/dtos/find-result-range.dto.ts @@ -0,0 +1,34 @@ +import { PaginationQueryDTO } from '@/common/dtos/pagination-query.dto'; +import { PartialType } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; +import { ResultRangeSortField } from '../enums/result-range-sort-field.enum'; + +export class FindResultRangeDTO extends PartialType(PaginationQueryDTO) { + @IsNumber() + @IsOptional() + id?: number; + + @IsNumber() + @IsOptional() + test?: number; + + @IsNumber() + @IsOptional() + minScore?: number; + + @IsNumber() + @IsOptional() + maxScore?: number; + + @IsString() + @IsOptional() + title?: string; + + @IsString() + @IsOptional() + description?: string; + + @IsEnum(ResultRangeSortField) + @IsOptional() + sortBy?: ResultRangeSortField; +} diff --git a/src/modules/scoring/dtos/update-result-range.dto.ts b/src/modules/scoring/dtos/update-result-range.dto.ts new file mode 100644 index 0000000..2afc022 --- /dev/null +++ b/src/modules/scoring/dtos/update-result-range.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateResultRangeDTO } from './create-result-range.dto'; + +export class UpdateResultRangeDTO extends PartialType(CreateResultRangeDTO) {} diff --git a/src/modules/scoring/enums/result-range-sort-field.enum.ts b/src/modules/scoring/enums/result-range-sort-field.enum.ts new file mode 100644 index 0000000..446a6b9 --- /dev/null +++ b/src/modules/scoring/enums/result-range-sort-field.enum.ts @@ -0,0 +1,8 @@ +export enum ResultRangeSortField { + ID = 'id', + TEST = 'test', + MIN_SCORE = 'minScore', + MAX_SCORE = 'maxScore', + TITLE = 'title', + DESCRIPTION = 'description', +} diff --git a/src/modules/scoring/helpers/filter-result-ranges.helper.ts b/src/modules/scoring/helpers/filter-result-ranges.helper.ts new file mode 100644 index 0000000..607ebf1 --- /dev/null +++ b/src/modules/scoring/helpers/filter-result-ranges.helper.ts @@ -0,0 +1,29 @@ +import { FindOptionsWhere, ILike } from 'typeorm'; +import { FindResultRangeDTO } from '../dtos/find-result-range.dto'; +import { ResultRange } from '../entities/result-range.entity'; + +export const filterResultRanges = (query: FindResultRangeDTO) => { + const where: FindOptionsWhere = {}; + + if (query.id) { + where.id = query.id; + } + + if (query.description) { + where.description = ILike(`%${query.description}%`); + } + + if (query.title) { + where.title = ILike(`%${query.title}%`); + } + + if (query.maxScore !== undefined) { + where.maxScore = query.maxScore; + } + + if (query.minScore !== undefined) { + where.minScore = query.minScore; + } + + return where; +}; diff --git a/src/modules/scoring/providers/result-range-command.provider.ts b/src/modules/scoring/providers/result-range-command.provider.ts new file mode 100644 index 0000000..a84c386 --- /dev/null +++ b/src/modules/scoring/providers/result-range-command.provider.ts @@ -0,0 +1,168 @@ +import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { ResultRange } from '../entities/result-range.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { CreateResultRangeDTO } from '../dtos/create-result-range.dto'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { AppException } from '@/common/exceptions/app.exception'; +import { TestsService } from '@/modules/tests/providers/tests.service'; +import { AppResponse } from '@/common/responses'; +import { I18nService } from 'nestjs-i18n'; +import { UpdateResultRangeDTO } from '../dtos/update-result-range.dto'; + +@Injectable() +export class ResultRangeCommandProvider { + constructor( + /** + * Inject ResultRange Repository + */ + @InjectRepository(ResultRange) + private readonly resultRangeRepository: Repository, + + /** + * Inject Tests Service + */ + private readonly testsService: TestsService, + + /** + * Inject I18n Service + */ + private readonly i18nService: I18nService, + ) {} + + private async checkRangeOverlap( + test: number, + minScore: number, + maxScore: number, + excludeSelf: boolean = false, + ): Promise { + const queryBuilder = this.resultRangeRepository + .createQueryBuilder('resultRange') + .where('resultRange.testId = :test', { test }) + .andWhere( + '(resultRange.minScore BETWEEN :minScore AND :maxScore) OR (resultRange.maxScore BETWEEN :minScore AND :maxScore) OR (:minScore BETWEEN resultRange.minScore AND resultRange.maxScore) OR (:maxScore BETWEEN resultRange.minScore AND resultRange.maxScore)', + { minScore, maxScore }, + ); + + let overlappingRanges: Array; + + try { + overlappingRanges = await queryBuilder.getMany(); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (overlappingRanges.length > (excludeSelf ? 1 : 0)) { + throw new AppException( + 'scoring.errors.resultRangeOverlappingConstraint', + HttpStatus.CONFLICT, + ); + } + } + + private async checkResultRangeExistance(resultRangeId: number) { + let resultRange: ResultRange | null = null; + + try { + resultRange = await this.resultRangeRepository.findOne({ + where: { + id: resultRangeId, + }, + relations: { + test: true, + }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!resultRange) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(scoring.fields.resultRange)' }, + ); + } + + return resultRange; + } + + public async createOne(createResultRangeDto: CreateResultRangeDTO) { + const test = await this.testsService.adminGetOne(createResultRangeDto.test); + + await this.checkRangeOverlap( + test.id, + createResultRangeDto.minScore, + createResultRangeDto.maxScore, + ); + + const newResultRange = this.resultRangeRepository.create({ + ...createResultRangeDto, + test, + }); + + try { + await this.resultRangeRepository.save(newResultRange); + return new AppResponse( + newResultRange, + this.i18nService.translate('common.messages.createSuccess', { + args: { entity: '$t(scoring.fields.resultRange)' }, + }), + ); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + public async updateOne( + id: number, + updateResultRangeDto: UpdateResultRangeDTO, + ) { + const resultRange = await this.checkResultRangeExistance(id); + + if (updateResultRangeDto.test) { + await this.testsService.adminGetOne(updateResultRangeDto.test); + } + + const testId = + updateResultRangeDto.test !== undefined + ? updateResultRangeDto.test + : resultRange.test.id; + + const minScore = + updateResultRangeDto.minScore !== undefined + ? updateResultRangeDto.minScore + : resultRange.minScore; + + const maxScore = + updateResultRangeDto.maxScore !== undefined + ? updateResultRangeDto.maxScore + : resultRange.maxScore; + + if (minScore >= maxScore) { + throw new BadRequestException( + 'scoring.errors.resultRangeMinMaxConstraint', + ); + } + + await this.checkRangeOverlap(testId, minScore, maxScore, true); + + Object.assign(resultRange, updateResultRangeDto); + + try { + return await this.resultRangeRepository.save(resultRange); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + public async deleteOne(id: number) { + const resultRange = await this.checkResultRangeExistance(id); + + try { + await this.resultRangeRepository.delete(resultRange.id); + } catch (err) { + throw new AppTimeoutException(err); + } + } +} diff --git a/src/modules/scoring/providers/result-range-query.provider.ts b/src/modules/scoring/providers/result-range-query.provider.ts new file mode 100644 index 0000000..211e0f8 --- /dev/null +++ b/src/modules/scoring/providers/result-range-query.provider.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; +import { FindManyOptions, Repository } from 'typeorm'; +import { ResultRange } from '../entities/result-range.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { TestsService } from '@/modules/tests/providers/tests.service'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { FindResultRangeDTO } from '../dtos/find-result-range.dto'; +import { filterResultRanges } from '../helpers/filter-result-ranges.helper'; +import Utils from '@/common/utils'; +import { Test } from '@/modules/tests/entities/test.entity'; +import { PaginatedResponse } from '@/common/responses'; + +@Injectable() +export class ResultRangeQueryProvider { + constructor( + /** + * Inject ResultRange Repository + */ + @InjectRepository(ResultRange) + private readonly resultRangeRepository: Repository, + + /** + * Inject Tests Service + */ + private readonly testsService: TestsService, + ) {} + + public async findAllForAdmin(query: FindResultRangeDTO) { + const filters = filterResultRanges(query); + const { limit, page, skip, take } = + Utils.PaginationUtils.getPaginationOptions(query); + + let selectedTest: Test | null = null; + + if (query.test) { + selectedTest = await this.testsService.adminGetOne(query.test); + } + + try { + const [resultRanges, total] = + await this.resultRangeRepository.findAndCount({ + where: { + ...filters, + ...(selectedTest && { + test: selectedTest, + }), + }, + loadRelationIds: true, + skip, + take, + order: query.sortBy + ? { + [query.sortBy]: query.order ?? 'ASC', + } + : { id: 'DESC' }, + }); + + return new PaginatedResponse(resultRanges, { + page, + limit, + total, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + public async findOneForAdmin(id: number) { + try { + return await this.resultRangeRepository.findOne({ + where: { id }, + relations: { test: true }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + public async findByOptions(options?: FindManyOptions) { + try { + return await this.resultRangeRepository.find(options); + } catch (err) { + throw new AppTimeoutException(err); + } + } +} diff --git a/src/modules/scoring/providers/result-range.service.ts b/src/modules/scoring/providers/result-range.service.ts new file mode 100644 index 0000000..0d7686f --- /dev/null +++ b/src/modules/scoring/providers/result-range.service.ts @@ -0,0 +1,55 @@ +import { Injectable } from '@nestjs/common'; +import { ResultRangeCommandProvider } from './result-range-command.provider'; +import { CreateResultRangeDTO } from '../dtos/create-result-range.dto'; +import { UpdateResultRangeDTO } from '../dtos/update-result-range.dto'; +import { FindResultRangeDTO } from '../dtos/find-result-range.dto'; +import { ResultRangeQueryProvider } from './result-range-query.provider'; +import { FindManyOptions } from 'typeorm'; +import { ResultRange } from '../entities/result-range.entity'; + +@Injectable() +export class ResultRangeService { + constructor( + /** + * Inject ResultRangeCommand Provider + */ + private readonly resultRangeCommandProvider: ResultRangeCommandProvider, + + /** + * Inject ResultRangeQuery Provider + */ + private readonly resultRangeQueryProvider: ResultRangeQueryProvider, + ) {} + + public async createOne(createResultRangeDto: CreateResultRangeDTO) { + return await this.resultRangeCommandProvider.createOne( + createResultRangeDto, + ); + } + + public async updateOne( + id: number, + updateResultRangeDto: UpdateResultRangeDTO, + ) { + return await this.resultRangeCommandProvider.updateOne( + id, + updateResultRangeDto, + ); + } + + public async deleteOne(id: number) { + return await this.resultRangeCommandProvider.deleteOne(id); + } + + public async findAllForAdmin(query: FindResultRangeDTO) { + return await this.resultRangeQueryProvider.findAllForAdmin(query); + } + + public async findOneForAdmin(id: number) { + return await this.resultRangeQueryProvider.findOneForAdmin(id); + } + + public async findByOptions(options: FindManyOptions) { + return await this.resultRangeQueryProvider.findByOptions(options); + } +} diff --git a/src/modules/scoring/scoring.module.ts b/src/modules/scoring/scoring.module.ts index 9f7a2dc..e594209 100644 --- a/src/modules/scoring/scoring.module.ts +++ b/src/modules/scoring/scoring.module.ts @@ -1,8 +1,18 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ResultRange } from './entities/result-range.entity'; +import { TestsModule } from '../tests/tests.module'; +import { ResultRangeCommandProvider } from './providers/result-range-command.provider'; +import { ResultRangeService } from './providers/result-range.service'; +import { ResultRangeQueryProvider } from './providers/result-range-query.provider'; @Module({ - imports: [TypeOrmModule.forFeature([ResultRange])], + imports: [TypeOrmModule.forFeature([ResultRange]), TestsModule], + providers: [ + ResultRangeCommandProvider, + ResultRangeQueryProvider, + ResultRangeService, + ], + exports: [ResultRangeService], }) export class ScoringModule {} diff --git a/src/modules/scoring/validators/result-range.validator.ts b/src/modules/scoring/validators/result-range.validator.ts new file mode 100644 index 0000000..e0e017e --- /dev/null +++ b/src/modules/scoring/validators/result-range.validator.ts @@ -0,0 +1,28 @@ +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { I18nContext } from 'nestjs-i18n'; + +@ValidatorConstraint({ name: 'QuestionRange', async: false }) +export class ResultRangeValidator implements ValidatorConstraintInterface { + validate( + value: any, + validationArguments?: ValidationArguments, + ): Promise | boolean { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const obj = validationArguments?.object as any; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { minScore, maxScore } = obj; + return minScore < maxScore; + } + defaultMessage?(): string { + const i18n = I18nContext.current(); + + return ( + i18n?.t('scoring.errors.resultRangeMinMaxConstraint') || + 'minScore value should be less than maxScore.' + ); + } +} diff --git a/src/modules/submissions/decorators/submission-answer.decorator.ts b/src/modules/submissions/decorators/submission-answer.decorator.ts new file mode 100644 index 0000000..28ed47a --- /dev/null +++ b/src/modules/submissions/decorators/submission-answer.decorator.ts @@ -0,0 +1,6 @@ +import { Validate } from 'class-validator'; +import { SubmissionAnswerValidator } from '../validators/submission-answer.validator'; + +export const SubmissionAnswer = () => { + return Validate(SubmissionAnswerValidator); +}; diff --git a/src/modules/submissions/dtos/answer-submission.dto.ts b/src/modules/submissions/dtos/answer-submission.dto.ts new file mode 100644 index 0000000..79d7e93 --- /dev/null +++ b/src/modules/submissions/dtos/answer-submission.dto.ts @@ -0,0 +1,15 @@ +import { IsNumber, IsOptional } from 'class-validator'; +import { SubmissionAnswer } from '../decorators/submission-answer.decorator'; + +export class AnswerSubmissionDTO { + @IsNumber() + @IsOptional() + choiceId?: number; + + @IsNumber() + @IsOptional() + numericValue?: number; + + @SubmissionAnswer() + private readonly _submissionAnswerValidation!: never; +} diff --git a/src/modules/submissions/dtos/start-submission.dto.ts b/src/modules/submissions/dtos/start-submission.dto.ts new file mode 100644 index 0000000..175e45c --- /dev/null +++ b/src/modules/submissions/dtos/start-submission.dto.ts @@ -0,0 +1,12 @@ +import { IsNotEmpty, IsNumber } from 'class-validator'; +import { i18nValidationMessage as t } from 'nestjs-i18n'; + +export class StartSubmissionDTO { + @IsNumber() + @IsNotEmpty({ + message: t('validation.requiredField', { + field: '$t(submissions.fields.testId)', + }), + }) + testId!: number; +} diff --git a/src/modules/submissions/entities/submission-answer.entity.ts b/src/modules/submissions/entities/submission-answer.entity.ts index 4759b18..7a2d05b 100644 --- a/src/modules/submissions/entities/submission-answer.entity.ts +++ b/src/modules/submissions/entities/submission-answer.entity.ts @@ -1,9 +1,16 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; import { Submission } from './submission.entity'; import { Question } from '@/modules/questions/entities/question.entity'; import { Choice } from '@/modules/questions/entities/choice.entity'; @Entity() +@Index(['submission', 'question'], { unique: true }) export class SubmissionAnswer { @PrimaryGeneratedColumn() id!: number; diff --git a/src/modules/submissions/entities/submission-result.entity.ts b/src/modules/submissions/entities/submission-result.entity.ts index 8419b5a..6f84222 100644 --- a/src/modules/submissions/entities/submission-result.entity.ts +++ b/src/modules/submissions/entities/submission-result.entity.ts @@ -3,7 +3,9 @@ import { Column, CreateDateColumn, Entity, + JoinColumn, ManyToOne, + OneToOne, PrimaryGeneratedColumn, } from 'typeorm'; import { Submission } from './submission.entity'; @@ -15,7 +17,8 @@ export class SubmissionResult { @PrimaryGeneratedColumn() id!: number; - @ManyToOne(() => Submission, { onDelete: 'CASCADE' }) + @OneToOne(() => Submission, { onDelete: 'CASCADE' }) + @JoinColumn() submission!: Submission; @Column({ type: 'int', nullable: false }) @@ -24,8 +27,8 @@ export class SubmissionResult { @Column({ type: 'int', nullable: false }) probability!: number; - @ManyToOne(() => ResultRange, { onDelete: 'CASCADE' }) - resultRange!: ResultRange; + @ManyToOne(() => ResultRange, { nullable: true, onDelete: 'CASCADE' }) + resultRange?: ResultRange; @CreateDateColumn() createdAt!: Date; diff --git a/src/modules/submissions/entities/submission.entity.ts b/src/modules/submissions/entities/submission.entity.ts index a0572bb..2ac65ce 100644 --- a/src/modules/submissions/entities/submission.entity.ts +++ b/src/modules/submissions/entities/submission.entity.ts @@ -6,18 +6,20 @@ import { Entity, ManyToOne, OneToMany, + OneToOne, PrimaryGeneratedColumn, } from 'typeorm'; import { SubmissionStatus } from '../enums/submission-status.enum'; import { SubmissionAnswer } from './submission-answer.entity'; +import { SubmissionResult } from './submission-result.entity'; @Entity() export class Submission { @PrimaryGeneratedColumn() id!: number; - @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) - user?: User; + @ManyToOne(() => User, { onDelete: 'CASCADE' }) + user!: User; @ManyToOne(() => Test, { onDelete: 'CASCADE' }) test!: Test; @@ -32,6 +34,9 @@ export class Submission { @OneToMany(() => SubmissionAnswer, (answer) => answer.submission) answers!: Array; + @OneToOne(() => SubmissionResult, (result) => result.submission) + result?: SubmissionResult; + @CreateDateColumn() createdAt!: Date; } diff --git a/src/modules/submissions/http/submissions.post.endpoints.http b/src/modules/submissions/http/submissions.post.endpoints.http new file mode 100644 index 0000000..e230a43 --- /dev/null +++ b/src/modules/submissions/http/submissions.post.endpoints.http @@ -0,0 +1,16 @@ +POST {{baseUrl}}/submissions/start +Authorization: Bearer {{$global.accessToken}} +Content-Type: application/json + +{ + "testId": 4 +} + +POST {{baseUrl}}/submissions/10/answer +Authorization: Bearer {{$global.accessToken}} +Content-Type: application/json + +{ + // "choiceId": 1 + "numericValue": 60 +} diff --git a/src/modules/submissions/interfaces/guest-submission-session.interfaces.ts b/src/modules/submissions/interfaces/guest-submission-session.interfaces.ts new file mode 100644 index 0000000..ccdde39 --- /dev/null +++ b/src/modules/submissions/interfaces/guest-submission-session.interfaces.ts @@ -0,0 +1,9 @@ +export interface GuestSubmissionSession { + testId: number; + currentQuestion: number; + answers: Array<{ + questionId: number; + choiceId: number; + numericValue: number; + }>; +} diff --git a/src/modules/submissions/providers/answer-submission.provider.ts b/src/modules/submissions/providers/answer-submission.provider.ts new file mode 100644 index 0000000..9780c6f --- /dev/null +++ b/src/modules/submissions/providers/answer-submission.provider.ts @@ -0,0 +1,321 @@ +import { + BadRequestException, + ForbiddenException, + HttpStatus, + Injectable, +} from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { Submission } from '../entities/submission.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { User } from '@/modules/users/entities/user.entity'; +import { AppException } from '@/common/exceptions/app.exception'; +import { RedisService } from '@/common/modules/redis/providers/redis.service'; +import { isUUID } from 'class-validator'; +import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interfaces'; +import { Question } from '@/modules/questions/entities/question.entity'; +import { AppResponse } from '@/common/responses'; +import { QuestionType } from '@/modules/questions/enums/question-type.enum'; +import { NumericMetadata } from '@/modules/questions/interfaces/numeric-metadata.interface'; +import { SubmissionAnswer } from '../entities/submission-answer.entity'; +import { ChoicesService } from '@/modules/questions/providers/choices.service'; +import { CalculateResultProvider } from './calculate-result.provider'; + +@Injectable() +export class AnswerSubmissionProvider { + constructor( + /** + * Inject Submission Repository + */ + @InjectRepository(Submission) + private readonly submissionRepository: Repository, + + /** + * Inject Redis Service + */ + private readonly redisService: RedisService, + + /** + * Inject SubmisionAnswer Repository + */ + @InjectRepository(SubmissionAnswer) + private readonly submissionAnswerRepository: Repository, + + /** + * Inject Choices Service + */ + private readonly choicesService: ChoicesService, + + /** + * Inject CalculateResult Provider + */ + private readonly calculateResultProvider: CalculateResultProvider, + ) {} + + public async answer( + id: string, + answerSubmissionDto: AnswerSubmissionDTO, + user?: User, + ) { + if (user) { + return await this.answerAuthenticated(id, answerSubmissionDto, user); + } else { + return await this.answerGuest(id, answerSubmissionDto); + } + } + + private async answerAuthenticated( + id: string, + answerSubmissionDto: AnswerSubmissionDTO, + user: User, + ) { + let submission: Submission | null = null; + + const submissionId = Number(id); + + if (Number.isNaN(submissionId)) { + throw new AppException( + 'validation.wrongFieldFormat', + HttpStatus.BAD_REQUEST, + { field: '$t(submissions.fields.submissionId)' }, + ); + } + + try { + submission = await this.submissionRepository.findOne({ + where: { id: submissionId }, + relations: { + user: true, + test: { + questions: { + choices: true, + rangeRules: true, + }, + }, + answers: { + question: true, + }, + result: { + resultRange: true, + }, + }, + order: { + answers: { + question: { + order: 'ASC', + }, + }, + test: { + questions: { + choices: { + order: 'ASC', + }, + }, + }, + }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!submission) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(submissions.fields.submission)' }, + ); + } + + if (submission.user.id !== user.id) { + throw new ForbiddenException('auth.errors.accessDenied'); + } + + const allQuestions = [...submission.test.questions].sort( + (a, b) => a.order - b.order, + ); + const answeredQuestions = submission.answers.map((el) => el.question); + + const questionStats = this.getQuestionStats( + allQuestions, + answeredQuestions, + ); + + if (!questionStats.current) { + return new AppResponse({ + isCompleted: true, + currentQuestion: null, + result: submission.result + ? { + score: submission.result.score, + probability: submission.result.probability, + title: submission.result.resultRange?.title, + description: submission.result.resultRange?.description, + } + : null, + }); + } + + this.checkAnswerCorrectness(questionStats.current, answerSubmissionDto); + + const submissionAnswer = this.submissionAnswerRepository.create({ + submission, + question: questionStats.current, + }); + + if ( + questionStats.current.type === QuestionType.MULTI_CHOICE && + answerSubmissionDto.choiceId + ) { + const choice = await this.choicesService.findOneForAdmin( + answerSubmissionDto.choiceId, + ); + + if (!choice) { + throw new AppException( + 'validation.wrongField', + HttpStatus.BAD_REQUEST, + { field: '$t(submissions.fields.choiceId)' }, + ); + } + + submissionAnswer.choice = choice; + } else if ( + questionStats.current.type === QuestionType.NUMERIC && + answerSubmissionDto.numericValue + ) { + submissionAnswer.numericValue = answerSubmissionDto.numericValue; + } + + try { + await this.submissionAnswerRepository.save(submissionAnswer); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!questionStats.next) { + const result = await this.calculateResultProvider.calculate(submission); + return new AppResponse({ + isCompleted: true, + currentQuestion: null, + result, + }); + } + + const nextQuestion = questionStats.next; + + return new AppResponse({ + isCompleted: false, + isFinal: + nextQuestion.id === allQuestions[allQuestions.length - 1].id + ? true + : undefined, + currentQuestion: { + type: nextQuestion.type, + content: nextQuestion.content, + required: nextQuestion.required, + metadata: nextQuestion.metadata, + choices: nextQuestion.choices, + }, + }); + } + + private async answerGuest( + id: string, + answerSubmissionDto: AnswerSubmissionDTO, + ) { + console.log(id, answerSubmissionDto); + + if (!isUUID(id)) { + throw new AppException( + 'validation.wrongFieldFormat', + HttpStatus.BAD_REQUEST, + { field: '$t(submissions.fields.submissionId)' }, + ); + } + + const submission = await this.redisService.get( + `guest_submission:${id}`, + ); + + return submission; + } + + private getQuestionStats( + allQuestions: Array, + answeredQuestions: Array, + ) { + const questionStats: Record<'prev' | 'current' | 'next', Question | null> = + { + prev: null, + current: null, + next: null, + }; + + if (allQuestions.length === 0) { + return questionStats; + } + + if (answeredQuestions.length > 0) { + questionStats.prev = answeredQuestions[answeredQuestions.length - 1]; + } + + if (!questionStats.prev) { + questionStats.current = allQuestions[0]; + } else { + const prevQuestionIdx = allQuestions.findIndex( + (val) => val.id === questionStats.prev?.id, + ); + + questionStats.current = allQuestions[prevQuestionIdx + 1]; + } + + if (questionStats.current) { + const currentQuestionIdx = allQuestions.findIndex( + (val) => val.id === questionStats.current?.id, + ); + + questionStats.next = allQuestions[currentQuestionIdx + 1]; + } + + return questionStats; + } + + private checkAnswerCorrectness( + question: Question, + answerSubmissionDto: AnswerSubmissionDTO, + ) { + if (!answerSubmissionDto) throw new BadRequestException(); + + const { choiceId, numericValue } = answerSubmissionDto; + + if (question.type === QuestionType.NUMERIC) { + if (numericValue === undefined) { + throw new BadRequestException('submissions.errors.emptyNumericValue'); + } + + const metadata = question.metadata as NumericMetadata; + + if ( + metadata && + (numericValue < metadata.min || numericValue > metadata.max) + ) { + throw new BadRequestException('submissions.errors.numericValueRange'); + } + } + + if (question.type === QuestionType.MULTI_CHOICE) { + if (!choiceId) { + throw new BadRequestException('submissions.errors.emptyChoiceId'); + } + + if (!question.choices.some((c) => c.id === choiceId)) { + throw new AppException( + 'validation.wrongField', + HttpStatus.BAD_REQUEST, + { field: '$t(submissions.fields.choiceId)' }, + ); + } + } + } +} diff --git a/src/modules/submissions/providers/calculate-result.provider.ts b/src/modules/submissions/providers/calculate-result.provider.ts new file mode 100644 index 0000000..defa3a3 --- /dev/null +++ b/src/modules/submissions/providers/calculate-result.provider.ts @@ -0,0 +1,114 @@ +import { Injectable } from '@nestjs/common'; +import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { SubmissionResult } from '../entities/submission-result.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Submission } from '../entities/submission.entity'; +import { QuestionType } from '@/modules/questions/enums/question-type.enum'; +import { QuestionsService } from '@/modules/questions/providers/questions.service'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { ResultRangeService } from '@/modules/scoring/providers/result-range.service'; + +@Injectable() +export class CalculateResultProvider { + constructor( + /** + * Inject SubmissionResult Repository + */ + @InjectRepository(SubmissionResult) + private readonly submissionResultRepository: Repository, + + /** + * Inject Questions Service + */ + private readonly questionsService: QuestionsService, + + /** + * Inject ResultRange Service + */ + private readonly resultRangeService: ResultRangeService, + ) {} + + public async calculate(submission: Submission) { + let score = 0; + + await Promise.all( + submission.answers.map(async (answer) => { + if ( + answer.question.type === QuestionType.MULTI_CHOICE && + answer.choice + ) { + score += answer.question.weight * answer.choice.score; + } else if ( + answer.question.type === QuestionType.NUMERIC && + answer.numericValue !== undefined + ) { + const question = await this.questionsService.findOneForAdmin( + answer.question.id, + ); + + if (question.rangeRules && question.rangeRules.length > 0) { + question.rangeRules.forEach((rule) => { + if ( + answer.numericValue && + answer.numericValue >= rule.min && + answer.numericValue <= rule.max + ) { + score += answer.question.weight * rule.score; + } + }); + } + } + }), + ); + + let totalScores = 0; + + submission.test.questions.forEach((question) => { + const answerScores: Array = []; + if (question.type === QuestionType.MULTI_CHOICE) { + question.choices.forEach((choice) => { + answerScores.push(choice.score); + }); + } else if ( + question.type === QuestionType.NUMERIC && + question.rangeRules && + question.rangeRules.length > 0 + ) { + question.rangeRules.forEach((rule) => { + answerScores.push(rule.score); + }); + } + + const maxScore = answerScores.length > 0 ? Math.max(...answerScores) : 0; + + totalScores += question.weight * maxScore; + }); + + const resultRanges = await this.resultRangeService.findByOptions({ + where: { + minScore: LessThanOrEqual(score), + maxScore: MoreThanOrEqual(score), + }, + }); + + const submissionResult = this.submissionResultRepository.create({ + submission, + score, + probability: Math.ceil((score / totalScores) * 100), + resultRange: resultRanges.length > 0 ? resultRanges[0] : undefined, + }); + + try { + await this.submissionResultRepository.save(submissionResult); + } catch (err) { + throw new AppTimeoutException(err); + } + + return { + score, + probability: submissionResult.probability, + title: submissionResult.resultRange?.title, + description: submissionResult.resultRange?.description, + }; + } +} diff --git a/src/modules/submissions/providers/start-submission.provider.ts b/src/modules/submissions/providers/start-submission.provider.ts new file mode 100644 index 0000000..a359a28 --- /dev/null +++ b/src/modules/submissions/providers/start-submission.provider.ts @@ -0,0 +1,142 @@ +import { AccessType } from '@/modules/tests/enums/access-types.enum'; +import { TestsService } from '@/modules/tests/providers/tests.service'; +import { User } from '@/modules/users/entities/user.entity'; +import { + BadRequestException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Repository } from 'typeorm'; +import { Submission } from '../entities/submission.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { AppResponse } from '@/common/responses'; +import { SubmissionStatus } from '../enums/submission-status.enum'; +import { Test } from '@/modules/tests/entities/test.entity'; +import { randomUUID } from 'crypto'; +import { RedisService } from '@/common/modules/redis/providers/redis.service'; +import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interfaces'; + +@Injectable() +export class StartSubmissionProvider { + constructor( + /** + * Inject Tests Service + */ + private readonly testsService: TestsService, + + /** + * Inject Submissions Repository + */ + @InjectRepository(Submission) + private readonly submissionsRepository: Repository, + + /** + * Inject Redis Service + */ + private readonly redisService: RedisService, + ) {} + + public async start(testId: number, user?: User) { + const test = await this.testsService.adminGetOne(testId, { + relations: { + questions: { + choices: true, + }, + }, + order: { + questions: { + order: 'ASC', + choices: { + order: 'ASC', + }, + }, + }, + select: { + questions: { + id: true, + order: true, + type: true, + content: true, + required: true, + metadata: true, + choices: { + id: true, + order: true, + text: true, + }, + }, + }, + }); + + if (test.accessType !== AccessType.FREE && !user) { + throw new ForbiddenException('auth.errors.accessDenied'); + } + + if (user) { + return await this.startAuthenticated(test, user); + } + + return await this.startGuest(test); + } + + private async startAuthenticated(test: Test, user: User) { + let submission: Submission | null = null; + + try { + submission = await this.submissionsRepository.findOne({ + where: { + test: { + id: test.id, + }, + user: { + id: user.id, + }, + }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (submission && submission.status === SubmissionStatus.COMPLETED) { + throw new BadRequestException('submissions.errors.alreadyTookTest'); + } + + if (!submission) { + submission = this.submissionsRepository.create({ + test, + user: user || undefined, + }); + + try { + await this.submissionsRepository.save(submission); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + return new AppResponse({ + submissionId: submission.id, + question: test.questions.length > 0 ? test.questions[0] : null, + }); + } + + private async startGuest(test: Test) { + const guestSubmissionId = randomUUID(); + + await this.redisService.set( + `guest_submission:${guestSubmissionId}`, + { + testId: test.id, + currentQuestion: 0, + answers: [], + }, + 3600, + ); + + return new AppResponse({ + guestSubmissionId, + question: test.questions[0], + }); + } +} diff --git a/src/modules/submissions/providers/submissions.service.ts b/src/modules/submissions/providers/submissions.service.ts new file mode 100644 index 0000000..cdb309f --- /dev/null +++ b/src/modules/submissions/providers/submissions.service.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { StartSubmissionProvider } from './start-submission.provider'; +import { User } from '@/modules/users/entities/user.entity'; +import { AnswerSubmissionProvider } from './answer-submission.provider'; +import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto'; + +@Injectable() +export class SubmissionsService { + constructor( + /** + * Inject Start Submission Provider + */ + private readonly startSubmissionProvider: StartSubmissionProvider, + + /** + * Inject Answer Submission Provider + */ + private readonly answerSubmissionProvider: AnswerSubmissionProvider, + ) {} + + public async startSubmission(testId: number, user?: User) { + return await this.startSubmissionProvider.start(testId, user); + } + + public async answerSubmission( + id: string, + answerSubmissionDto: AnswerSubmissionDTO, + user?: User, + ) { + return await this.answerSubmissionProvider.answer( + id, + answerSubmissionDto, + user, + ); + } +} diff --git a/src/modules/submissions/submissions.controller.ts b/src/modules/submissions/submissions.controller.ts new file mode 100644 index 0000000..65d2add --- /dev/null +++ b/src/modules/submissions/submissions.controller.ts @@ -0,0 +1,56 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { Public } from '../auth/decorators/public.decorator'; +import { OptionalJwtGuard } from '../auth/guards/optional-jwt.guard'; +import { User } from '../users/entities/user.entity'; +import { ActiveUser } from '@/common/decorators/active-user.decorator'; +import { SubmissionsService } from './providers/submissions.service'; +import { StartSubmissionDTO } from './dtos/start-submission.dto'; +import { AnswerSubmissionDTO } from './dtos/answer-submission.dto'; + +@Controller('submissions') +export class SubmissionsController { + constructor( + /** + * Inject Submissions Service + */ + private readonly submissionsService: SubmissionsService, + ) {} + + @Public() + @Post('start') + @HttpCode(HttpStatus.OK) + @UseGuards(OptionalJwtGuard) + public async startTest( + @Body() startSubmissionDto: StartSubmissionDTO, + @ActiveUser() user?: User, + ) { + return await this.submissionsService.startSubmission( + startSubmissionDto.testId, + user, + ); + } + + @Public() + @Post(':id/answer') + @HttpCode(HttpStatus.OK) + @UseGuards(OptionalJwtGuard) + public async answerSubmission( + @Param('id') id: string, + @Body() answerSubmissionDto: AnswerSubmissionDTO, + @ActiveUser() user?: User, + ) { + return await this.submissionsService.answerSubmission( + id, + answerSubmissionDto, + user, + ); + } +} diff --git a/src/modules/submissions/submissions.module.ts b/src/modules/submissions/submissions.module.ts index bb5c8d7..6327f3f 100644 --- a/src/modules/submissions/submissions.module.ts +++ b/src/modules/submissions/submissions.module.ts @@ -3,10 +3,30 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { Submission } from './entities/submission.entity'; import { SubmissionAnswer } from './entities/submission-answer.entity'; import { SubmissionResult } from './entities/submission-result.entity'; +import { SubmissionsController } from './submissions.controller'; +import { StartSubmissionProvider } from './providers/start-submission.provider'; +import { SubmissionsService } from './providers/submissions.service'; +import { TestsModule } from '../tests/tests.module'; +import { AnswerSubmissionProvider } from './providers/answer-submission.provider'; +import { RedisModule } from '@/common/modules/redis/redis.module'; +import { QuestionsModule } from '../questions/questions.module'; +import { CalculateResultProvider } from './providers/calculate-result.provider'; +import { ScoringModule } from '../scoring/scoring.module'; @Module({ imports: [ TypeOrmModule.forFeature([Submission, SubmissionAnswer, SubmissionResult]), + TestsModule, + QuestionsModule, + RedisModule, + ScoringModule, + ], + controllers: [SubmissionsController], + providers: [ + StartSubmissionProvider, + AnswerSubmissionProvider, + CalculateResultProvider, + SubmissionsService, ], }) export class SubmissionsModule {} diff --git a/src/modules/submissions/validators/submission-answer.validator.ts b/src/modules/submissions/validators/submission-answer.validator.ts new file mode 100644 index 0000000..6e3799b --- /dev/null +++ b/src/modules/submissions/validators/submission-answer.validator.ts @@ -0,0 +1,30 @@ +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { I18nContext } from 'nestjs-i18n'; + +@ValidatorConstraint({ name: 'SubmissionAnswer', async: false }) +export class SubmissionAnswerValidator implements ValidatorConstraintInterface { + validate( + value: any, + validationArguments?: ValidationArguments, + ): Promise | boolean { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const obj = validationArguments?.object as any; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { choiceId, numericValue } = obj; + + return choiceId !== undefined || numericValue !== undefined; + } + + defaultMessage?(): string { + const i18n = I18nContext.current(); + + return ( + i18n?.t('submissions.errors.choiceNumericConstraint') ?? + 'You must send either choiceId or numericValue.' + ); + } +} diff --git a/src/modules/tests/providers/tests-query.provider.ts b/src/modules/tests/providers/tests-query.provider.ts index 3db56f4..cc0db31 100644 --- a/src/modules/tests/providers/tests-query.provider.ts +++ b/src/modules/tests/providers/tests-query.provider.ts @@ -1,6 +1,6 @@ import { HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { FindOneOptions, Repository } from 'typeorm'; import { PaginatedResponse } from '@/common/responses'; import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; import { FindTestsDTO } from '../dtos/find-tests.dto'; @@ -64,12 +64,27 @@ export class TestsQueryProvider { } } - public async findOneForAdmin(id: number) { + public async findOneForAdmin(id: number, options?: FindOneOptions) { + let test: Test | null = null; + try { - return await this.testRepository.findOneBy({ id }); + test = await this.testRepository.findOne({ + where: { id }, + ...options, + }); } catch (err) { throw new AppTimeoutException(err); } + + if (!test) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(tests.fields.test)' }, + ); + } + + return test; } public async findOneForUser(id: number) { diff --git a/src/modules/tests/providers/tests.service.ts b/src/modules/tests/providers/tests.service.ts index df94218..942719f 100644 --- a/src/modules/tests/providers/tests.service.ts +++ b/src/modules/tests/providers/tests.service.ts @@ -4,6 +4,8 @@ import { UpdateTestDTO } from '../dtos/update-test.dto'; import { FindTestsDTO } from '../dtos/find-tests.dto'; import { TestsQueryProvider } from './tests-query.provider'; import { TestsCommandProvider } from './tests-command.provider'; +import { FindOneOptions } from 'typeorm'; +import { Test } from '../entities/test.entity'; @Injectable() export class TestsService { @@ -31,8 +33,8 @@ export class TestsService { return await this.testsQueryProvider.findAllForUser(); } - public async adminGetOne(id: number) { - return await this.testsQueryProvider.findOneForAdmin(id); + public async adminGetOne(id: number, options?: FindOneOptions) { + return await this.testsQueryProvider.findOneForAdmin(id, options); } public async userGetOne(id: number) {