diff --git a/src/common/modules/redis/providers/redis.service.ts b/src/common/modules/redis/providers/redis.service.ts index f7f37c9..ebbeab8 100644 --- a/src/common/modules/redis/providers/redis.service.ts +++ b/src/common/modules/redis/providers/redis.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { REDIS } from '../constants/redis.constants'; import Redis from 'ioredis'; +import { randomUUID } from 'crypto'; @Injectable() export class RedisService { @@ -38,6 +39,22 @@ export class RedisService { return await this.redis.del(key); } + async acquireLock(key: string, ttlSeconds = 5): Promise { + const token = randomUUID(); + + const result = await this.redis.set(key, token, 'EX', ttlSeconds, 'NX'); + + return result === 'OK' ? token : null; + } + + async releaseLock(key: string, token: string): Promise { + const value = await this.redis.get(key); + + if (value === token) { + await this.redis.del(key); + } + } + async onModuleDestroy() { await this.redis.quit(); } diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json index cf69587..6590860 100644 --- a/src/i18n/en/common.json +++ b/src/i18n/en/common.json @@ -3,7 +3,8 @@ "internalServerError": "Internal Server Error", "tooManyRequests": "Too many requests! Try again after {minutes} min.", "requestTimeout": "Unable to process your request at the moment.", - "entityNotFound": "{entity} not found." + "entityNotFound": "{entity} not found.", + "sameProcessInProgress": "Same process is in progress." }, "messages": { "createSuccess": "{entity} created successfully.", diff --git a/src/i18n/en/submissions.json b/src/i18n/en/submissions.json index 9d7182e..7a19cb8 100644 --- a/src/i18n/en/submissions.json +++ b/src/i18n/en/submissions.json @@ -12,6 +12,7 @@ "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.", - "transitionNotFound": "No transition found for the test" + "transitionNotFound": "No transition found for the test", + "uniqueSubmissionQuestion": "Question's Answer already exists." } } diff --git a/src/i18n/fa/common.json b/src/i18n/fa/common.json index 0a2074f..05b6551 100644 --- a/src/i18n/fa/common.json +++ b/src/i18n/fa/common.json @@ -3,7 +3,8 @@ "internalServerError": "خطایی رخ داد", "tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.", "requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد.", - "entityNotFound": "{entity} مورد نظر یافت نشد." + "entityNotFound": "{entity} مورد نظر یافت نشد.", + "sameProcessInProgress": "فرایند مشابهی در حال اجراست." }, "messages": { "createSuccess": "{entity} با موفقیت ایجاد شد.", diff --git a/src/i18n/fa/submissions.json b/src/i18n/fa/submissions.json index 8918069..f3c7b3a 100644 --- a/src/i18n/fa/submissions.json +++ b/src/i18n/fa/submissions.json @@ -12,6 +12,7 @@ "emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.", "numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.", "emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود.", - "transitionNotFound": "مسیری برای تست پیدا نشد." + "transitionNotFound": "مسیری برای تست پیدا نشد.", + "uniqueSubmissionQuestion": "این سوال قبلا پاسخ داده شده است." } } diff --git a/src/modules/scoring/scoring.module.ts b/src/modules/scoring/scoring.module.ts index e594209..5c83af9 100644 --- a/src/modules/scoring/scoring.module.ts +++ b/src/modules/scoring/scoring.module.ts @@ -13,6 +13,6 @@ import { ResultRangeQueryProvider } from './providers/result-range-query.provide ResultRangeQueryProvider, ResultRangeService, ], - exports: [ResultRangeService], + exports: [ResultRangeService, TypeOrmModule], }) export class ScoringModule {} diff --git a/src/modules/submissions/constants/index.ts b/src/modules/submissions/constants/index.ts new file mode 100644 index 0000000..8cde576 --- /dev/null +++ b/src/modules/submissions/constants/index.ts @@ -0,0 +1 @@ +export const UNIQUE_SUBMISSION_QUESTION_IDX = 'UNQ_SUBMISSION_QUESTION_IDX'; diff --git a/src/modules/submissions/entities/submission-answer.entity.ts b/src/modules/submissions/entities/submission-answer.entity.ts index 7a2d05b..0bd6ca6 100644 --- a/src/modules/submissions/entities/submission-answer.entity.ts +++ b/src/modules/submissions/entities/submission-answer.entity.ts @@ -8,9 +8,12 @@ import { import { Submission } from './submission.entity'; import { Question } from '@/modules/questions/entities/question.entity'; import { Choice } from '@/modules/questions/entities/choice.entity'; +import { UNIQUE_SUBMISSION_QUESTION_IDX } from '../constants'; @Entity() -@Index(['submission', 'question'], { unique: true }) +@Index(UNIQUE_SUBMISSION_QUESTION_IDX, ['submission', 'question'], { + unique: true, +}) export class SubmissionAnswer { @PrimaryGeneratedColumn() id!: number; diff --git a/src/modules/submissions/helpers/check-answer-correctness.helper.ts b/src/modules/submissions/helpers/check-answer-correctness.helper.ts new file mode 100644 index 0000000..7b734d7 --- /dev/null +++ b/src/modules/submissions/helpers/check-answer-correctness.helper.ts @@ -0,0 +1,42 @@ +import { Question } from '@/modules/questions/entities/question.entity'; +import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto'; +import { BadRequestException, HttpStatus } from '@nestjs/common'; +import { QuestionType } from '@/modules/questions/enums/question-type.enum'; +import { NumericMetadata } from '@/modules/questions/interfaces/numeric-metadata.interface'; +import { AppException } from '@/common/exceptions/app.exception'; + +export const 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 === undefined || choiceId === null) { + 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/helpers/get-next-transition.helper.ts b/src/modules/submissions/helpers/get-next-transition.helper.ts new file mode 100644 index 0000000..60f7cd3 --- /dev/null +++ b/src/modules/submissions/helpers/get-next-transition.helper.ts @@ -0,0 +1,63 @@ +import { Question } from '@/modules/questions/entities/question.entity'; +import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto'; +import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum'; +import { QuestionType } from '@/modules/questions/enums/question-type.enum'; +import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-choice-condition.interface'; +import { NumericCondition } from '@/modules/questions/interfaces/numeric-condition.interface'; +import { AppException } from '@/common/exceptions/app.exception'; +import { HttpStatus } from '@nestjs/common'; + +export const getNextTransition = ( + question: Question, + answerSubmissionDto: AnswerSubmissionDTO, +) => { + const transitions = question.outgoingTransitions ?? []; + + const endTransition = transitions.find( + (t) => t.type === QuestionTransitionType.END, + ); + + if ( + question.type === QuestionType.MULTI_CHOICE && + answerSubmissionDto.choiceId !== undefined + ) { + const transition = transitions.find( + (t) => + t.type === QuestionTransitionType.CHOICE && + (t.condition as SingleChoiceCondition).choiceId === + answerSubmissionDto.choiceId, + ); + + if (transition) return transition; + } + + if ( + question.type === QuestionType.NUMERIC && + answerSubmissionDto.numericValue !== undefined + ) { + const value = answerSubmissionDto.numericValue; + + const transition = transitions.find((t) => { + if (t.type !== QuestionTransitionType.NUMBER_RANGE) return false; + + const condition = t.condition as NumericCondition; + + return value >= condition.min && value <= condition.max; + }); + + if (transition) return transition; + } + + const defaultTransition = transitions.find( + (t) => t.type === QuestionTransitionType.DEFAULT, + ); + + if (defaultTransition) return defaultTransition; + + if (endTransition) return endTransition; + + throw new AppException( + 'submissions.errors.nextQuestionNotFound', + HttpStatus.BAD_REQUEST, + ); +}; diff --git a/src/modules/submissions/providers/answer-submission.provider.ts b/src/modules/submissions/providers/answer-submission.provider.ts deleted file mode 100644 index 7005c2e..0000000 --- a/src/modules/submissions/providers/answer-submission.provider.ts +++ /dev/null @@ -1,452 +0,0 @@ -import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common'; -import { DataSource, 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.interface'; -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 { CalculateResultProvider } from './calculate-result.provider'; -import { SubmissionResult } from '../entities/submission-result.entity'; -import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum'; -import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-choice-condition.interface'; -import { NumericCondition } from '@/modules/questions/interfaces/numeric-condition.interface'; -import { SubmissionStatus } from '../enums/submission-status.enum'; -import { GuestSubmissionAnswer } from '../interfaces/guest-submission-answer.interface'; - -@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 SubmissionResult Repository - */ - @InjectRepository(SubmissionResult) - private readonly submissionResultRepository: Repository, - - /** - * Inject Questions Repository - */ - @InjectRepository(Question) - private readonly questionsRepository: Repository, - - /** - * Inject CalculateResult Provider - */ - private readonly calculateResultProvider: CalculateResultProvider, - - /** - * Inject DataSource - */ - private readonly dataSource: DataSource, - ) {} - - 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, user: { id: user.id } }, - relations: { - test: true, - currentQuestion: { - choices: true, - outgoingTransitions: true, - }, - }, - }); - } catch (err) { - throw new AppTimeoutException(err); - } - - if (!submission) { - throw new AppException( - 'common.errors.entityNotFound', - HttpStatus.NOT_FOUND, - { entity: '$t(submissions.fields.submission)' }, - ); - } - - const currentQuestion = submission.currentQuestion; - - if (!currentQuestion) { - let result: SubmissionResult | null; - - try { - result = await this.submissionResultRepository.findOne({ - where: { submission: { id: submission.id } }, - relations: { - resultRange: true, - }, - }); - } catch (err) { - throw new AppTimeoutException(err); - } - - return new AppResponse({ - isCompleted: true, - currentQuestion: null, - result: result - ? { - score: result.score, - probability: result.probability, - title: result.resultRange?.title, - description: result.resultRange?.description, - } - : null, - }); - } - - this.checkAnswerCorrectness(currentQuestion, answerSubmissionDto); - - const submissionAnswer = this.submissionAnswerRepository.create({ - submission, - question: currentQuestion, - }); - - if ( - currentQuestion.type === QuestionType.MULTI_CHOICE && - answerSubmissionDto.choiceId !== undefined - ) { - const choice = currentQuestion.choices.find( - (el) => el.id === answerSubmissionDto.choiceId, - ); - - if (!choice) { - throw new AppException( - 'validation.wrongField', - HttpStatus.BAD_REQUEST, - { field: '$t(submissions.fields.choiceId)' }, - ); - } - - submissionAnswer.choice = choice; - } else if ( - currentQuestion.type === QuestionType.NUMERIC && - answerSubmissionDto.numericValue !== undefined - ) { - submissionAnswer.numericValue = answerSubmissionDto.numericValue; - } - - const nextTransition = this.getNextTransition( - currentQuestion, - answerSubmissionDto, - ); - - const nextQuestion = nextTransition.toQuestion ?? null; - - let completedResponse: object | null = null; - - try { - completedResponse = await this.dataSource.transaction(async (manager) => { - await manager.save(submissionAnswer); - - if ( - nextTransition.type === QuestionTransitionType.END || - !nextQuestion - ) { - await manager.update( - Submission, - { id: submission.id }, - { currentQuestion: null, status: SubmissionStatus.COMPLETED }, - ); - - const result = - await this.calculateResultProvider.calculateAuthenticated( - submission, - manager, - ); - - return { - isCompleted: true, - currentQuestion: null, - result, - }; - } - - await manager.update( - Submission, - { id: submission.id }, - { currentQuestion: nextQuestion }, - ); - - return null; - }); - } catch (err) { - throw new AppTimeoutException(err); - } - - if (completedResponse) { - return new AppResponse(completedResponse); - } - - return new AppResponse({ - isCompleted: false, - // isFinal: , - currentQuestion: { - type: nextQuestion?.type, - content: nextQuestion?.content, - required: nextQuestion?.required, - metadata: nextQuestion?.metadata, - choices: nextQuestion?.choices.map((el) => ({ - id: el.id, - text: el.text, - order: el.order, - })), - }, - }); - } - - 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}`, - ); - - if (!submission) { - throw new AppException( - 'common.errors.entityNotFound', - HttpStatus.NOT_FOUND, - { entity: '$t(submissions.fields.submission)' }, - ); - } - - let currentQuestion: Question | null = null; - - if (submission.currentQuestionId !== null) { - try { - currentQuestion = await this.questionsRepository.findOne({ - where: { id: submission.currentQuestionId }, - relations: { choices: true, outgoingTransitions: true }, - }); - } catch (err) { - throw new AppTimeoutException(err); - } - } - - if (!currentQuestion) { - return new AppResponse({ - isCompleted: true, - currentQuestion: null, - result: submission.result - ? { - score: submission.result.score, - probability: submission.result.probability, - title: submission.result.title, - description: submission.result.description, - } - : null, - }); - } - - this.checkAnswerCorrectness(currentQuestion, answerSubmissionDto); - - const newAnswer: GuestSubmissionAnswer = { - questionId: currentQuestion.id, - }; - - if ( - currentQuestion.type === QuestionType.MULTI_CHOICE && - answerSubmissionDto.choiceId !== undefined - ) { - const choice = currentQuestion.choices.find( - (el) => el.id === answerSubmissionDto.choiceId, - ); - - if (!choice) { - throw new AppException( - 'validation.wrongField', - HttpStatus.BAD_REQUEST, - { field: '$t(submissions.fields.choiceId)' }, - ); - } - - newAnswer.choiceId = choice.id; - } else if ( - currentQuestion.type === QuestionType.NUMERIC && - answerSubmissionDto.numericValue !== undefined - ) { - newAnswer.numericValue = answerSubmissionDto.numericValue; - } - - const nextTransition = this.getNextTransition( - currentQuestion, - answerSubmissionDto, - ); - - const nextQuestion = nextTransition.toQuestion ?? null; - - try { - if (nextTransition.type === QuestionTransitionType.END || !nextQuestion) { - await this.redisService.set( - `guest_submission:${id}`, - { - ...submission, - currentQuestionId: null, - answers: [...submission.answers, newAnswer], - }, - 3600, - ); - } - } catch (err) { - throw new AppTimeoutException(err); - } - - return submission; - } - - 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)' }, - ); - } - } - } - - private getNextTransition( - question: Question, - answerSubmissionDto: AnswerSubmissionDTO, - ) { - const transitions = question.outgoingTransitions ?? []; - - const endTransition = transitions.find( - (t) => t.type === QuestionTransitionType.END, - ); - - if ( - question.type === QuestionType.MULTI_CHOICE && - answerSubmissionDto.choiceId !== undefined - ) { - const transition = transitions.find( - (t) => - t.type === QuestionTransitionType.CHOICE && - (t.condition as SingleChoiceCondition).choiceId === - answerSubmissionDto.choiceId, - ); - - if (transition) return transition; - } - - if ( - question.type === QuestionType.NUMERIC && - answerSubmissionDto.numericValue !== undefined - ) { - const value = answerSubmissionDto.numericValue; - - const transition = transitions.find((t) => { - if (t.type !== QuestionTransitionType.NUMBER_RANGE) return false; - - const condition = t.condition as NumericCondition; - - return value >= condition.min && value <= condition.max; - }); - - if (transition) return transition; - } - - const defaultTransition = transitions.find( - (t) => t.type === QuestionTransitionType.DEFAULT, - ); - - if (defaultTransition) return defaultTransition; - - if (endTransition) return endTransition; - - throw new AppException( - 'submissions.errors.nextQuestionNotFound', - HttpStatus.BAD_REQUEST, - ); - } -} diff --git a/src/modules/submissions/providers/answer/answer-authenticated.provider.ts b/src/modules/submissions/providers/answer/answer-authenticated.provider.ts new file mode 100644 index 0000000..c99e933 --- /dev/null +++ b/src/modules/submissions/providers/answer/answer-authenticated.provider.ts @@ -0,0 +1,235 @@ +import { ConflictException, HttpStatus, Injectable } from '@nestjs/common'; +import { AnswerSubmissionDTO } from '../../dtos/answer-submission.dto'; +import { User } from '@/modules/users/entities/user.entity'; +import { Submission } from '../../entities/submission.entity'; +import { AppException } from '@/common/exceptions/app.exception'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { SubmissionResult } from '../../entities/submission-result.entity'; +import { AppResponse } from '@/common/responses'; +import { checkAnswerCorrectness } from '../../helpers/check-answer-correctness.helper'; +import { QuestionType } from '@/modules/questions/enums/question-type.enum'; +import { getNextTransition } from '../../helpers/get-next-transition.helper'; +import { DataSource, QueryFailedError, Repository } from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; +import { SubmissionAnswer } from '../../entities/submission-answer.entity'; +import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum'; +import { SubmissionStatus } from '../../enums/submission-status.enum'; +import { CalculateResultProvider } from './calculate-result.provider'; +import { UNIQUE_SUBMISSION_QUESTION_IDX } from '../../constants'; + +@Injectable() +export class AnswerAuthenticatedProvider { + constructor( + /** + * Inject Submission Repository + */ + @InjectRepository(Submission) + private readonly submissionRepository: Repository, + + /** + * Inject SubmisionAnswer Repository + */ + @InjectRepository(SubmissionAnswer) + private readonly submissionAnswerRepository: Repository, + + /** + * Inject SubmissionResult Repository + */ + @InjectRepository(SubmissionResult) + private readonly submissionResultRepository: Repository, + + /** + * Inject DataSource + */ + private readonly dataSource: DataSource, + + /** + * Inject CalculateResult Provider + */ + private readonly calculateResultProvider: CalculateResultProvider, + ) {} + + public async answer( + 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, user: { id: user.id } }, + relations: { + test: true, + currentQuestion: { + choices: true, + outgoingTransitions: { + toQuestion: { + choices: true, + }, + }, + }, + }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!submission) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(submissions.fields.submission)' }, + ); + } + + const currentQuestion = submission.currentQuestion; + + if (!currentQuestion) { + let result: SubmissionResult | null; + + try { + result = await this.submissionResultRepository.findOne({ + where: { submission: { id: submission.id } }, + relations: { + resultRange: true, + }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + + return new AppResponse({ + isCompleted: true, + currentQuestion: null, + result: result + ? { + score: result.score, + probability: result.probability, + title: result.resultRange?.title, + description: result.resultRange?.description, + } + : null, + }); + } + + checkAnswerCorrectness(currentQuestion, answerSubmissionDto); + + const submissionAnswer = this.submissionAnswerRepository.create({ + submission, + question: currentQuestion, + }); + + if ( + currentQuestion.type === QuestionType.MULTI_CHOICE && + answerSubmissionDto.choiceId !== undefined + ) { + const choice = currentQuestion.choices.find( + (el) => el.id === answerSubmissionDto.choiceId, + ); + + if (!choice) { + throw new AppException( + 'validation.wrongField', + HttpStatus.BAD_REQUEST, + { field: '$t(submissions.fields.choiceId)' }, + ); + } + + submissionAnswer.choice = choice; + } else if ( + currentQuestion.type === QuestionType.NUMERIC && + answerSubmissionDto.numericValue !== undefined + ) { + submissionAnswer.numericValue = answerSubmissionDto.numericValue; + } + + const nextTransition = getNextTransition( + currentQuestion, + answerSubmissionDto, + ); + + const nextQuestion = nextTransition.toQuestion ?? null; + + let completedResponse: object | null = null; + + try { + completedResponse = await this.dataSource.transaction(async (manager) => { + await manager.save(submissionAnswer); + + if ( + nextTransition.type === QuestionTransitionType.END || + !nextQuestion + ) { + await manager.update( + Submission, + { id: submission.id }, + { currentQuestion: null, status: SubmissionStatus.COMPLETED }, + ); + + const result = + await this.calculateResultProvider.calculateAuthenticated( + submission, + manager, + ); + + return { + isCompleted: true, + currentQuestion: null, + result, + }; + } + + await manager.update( + Submission, + { id: submission.id }, + { currentQuestion: nextQuestion }, + ); + + return null; + }); + } catch (err) { + if ( + err instanceof QueryFailedError && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (err as any).constraint === UNIQUE_SUBMISSION_QUESTION_IDX + ) { + throw new ConflictException( + 'submissions.errors.uniqueSubmissionQuestion', + ); + } + + throw new AppTimeoutException(err); + } + + if (completedResponse) { + return new AppResponse(completedResponse); + } + + return new AppResponse({ + isCompleted: false, + // isFinal: , + currentQuestion: { + type: nextQuestion?.type, + content: nextQuestion?.content, + required: nextQuestion?.required, + metadata: nextQuestion?.metadata, + choices: nextQuestion?.choices.map((el) => ({ + id: el.id, + text: el.text, + order: el.order, + })), + }, + }); + } +} diff --git a/src/modules/submissions/providers/answer/answer-guest.provider.ts b/src/modules/submissions/providers/answer/answer-guest.provider.ts new file mode 100644 index 0000000..0f9e3a8 --- /dev/null +++ b/src/modules/submissions/providers/answer/answer-guest.provider.ts @@ -0,0 +1,206 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { AnswerSubmissionDTO } from '../../dtos/answer-submission.dto'; +import { isUUID } from 'class-validator'; +import { AppException } from '@/common/exceptions/app.exception'; +import { GuestSubmissionSession } from '../../interfaces/guest-submission-session.interface'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { Question } from '@/modules/questions/entities/question.entity'; +import { AppResponse } from '@/common/responses'; +import { checkAnswerCorrectness } from '../../helpers/check-answer-correctness.helper'; +import { GuestSubmissionAnswer } from '../../interfaces/guest-submission-answer.interface'; +import { QuestionType } from '@/modules/questions/enums/question-type.enum'; +import { getNextTransition } from '../../helpers/get-next-transition.helper'; +import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum'; +import { InjectRepository } from '@nestjs/typeorm'; +import { RedisService } from '@/common/modules/redis/providers/redis.service'; +import { Repository } from 'typeorm'; +import { CalculateResultProvider } from './calculate-result.provider'; + +@Injectable() +export class AnswerGuestProvider { + constructor( + /** + * Inject Redis Service + */ + private readonly redisService: RedisService, + + /** + * Inject Questions Repository + */ + @InjectRepository(Question) + private readonly questionsRepository: Repository, + + /** + * Inject CalculateResult Provider + */ + private readonly calculateResultProvider: CalculateResultProvider, + ) {} + + public async answer(id: string, answerSubmissionDto: AnswerSubmissionDTO) { + if (!isUUID(id)) { + throw new AppException( + 'validation.wrongFieldFormat', + HttpStatus.BAD_REQUEST, + { field: '$t(submissions.fields.submissionId)' }, + ); + } + + const lockKey = `lock:guest_submission:${id}`; + const lockToken = await this.redisService.acquireLock(lockKey, 5); + + if (!lockToken) { + throw new AppException( + 'common.errors.processingInProgress', + HttpStatus.CONFLICT, + ); + } + + try { + return this.answerGuest(id, answerSubmissionDto); + } finally { + await this.redisService.releaseLock(lockKey, lockToken); + } + } + + private async answerGuest( + id: string, + answerSubmissionDto: AnswerSubmissionDTO, + ) { + let submission: GuestSubmissionSession | null = null; + + try { + submission = await this.redisService.get( + `guest_submission:${id}`, + ); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!submission) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(submissions.fields.submission)' }, + ); + } + + let currentQuestion: Question | null = null; + + if (submission.currentQuestionId !== null) { + try { + currentQuestion = await this.questionsRepository.findOne({ + where: { id: submission.currentQuestionId }, + relations: { + choices: true, + outgoingTransitions: { + toQuestion: { + choices: true, + }, + }, + }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + if (!currentQuestion) { + return new AppResponse({ + isCompleted: true, + currentQuestion: null, + result: submission.result + ? { + score: submission.result.score, + probability: submission.result.probability, + title: submission.result.title, + description: submission.result.description, + } + : null, + }); + } + + checkAnswerCorrectness(currentQuestion, answerSubmissionDto); + + const newAnswer: GuestSubmissionAnswer = { + questionId: currentQuestion.id, + }; + + if ( + currentQuestion.type === QuestionType.MULTI_CHOICE && + answerSubmissionDto.choiceId !== undefined + ) { + const choice = currentQuestion.choices.find( + (el) => el.id === answerSubmissionDto.choiceId, + ); + + if (!choice) { + throw new AppException( + 'validation.wrongField', + HttpStatus.BAD_REQUEST, + { field: '$t(submissions.fields.choiceId)' }, + ); + } + + newAnswer.choiceId = choice.id; + } else if ( + currentQuestion.type === QuestionType.NUMERIC && + answerSubmissionDto.numericValue !== undefined + ) { + newAnswer.numericValue = answerSubmissionDto.numericValue; + } + + const nextTransition = getNextTransition( + currentQuestion, + answerSubmissionDto, + ); + + const nextQuestion = nextTransition.toQuestion ?? null; + + submission.answers = [...submission.answers, newAnswer]; + + try { + if (nextTransition.type === QuestionTransitionType.END || !nextQuestion) { + submission.currentQuestionId = null; + + const result = + await this.calculateResultProvider.calculateGuest(submission); + + await this.redisService.set( + `guest_submission:${id}`, + submission, + 3600, + ); + + return new AppResponse({ + isCompleted: true, + currentQuestion: null, + result, + }); + } + + await this.redisService.set( + `guest_submission:${id}`, + { ...submission, currentQuestionId: nextQuestion.id }, + 3600, + ); + } catch (err) { + throw new AppTimeoutException(err); + } + + return new AppResponse({ + isCompleted: false, + // isFinal: , + currentQuestion: { + type: nextQuestion?.type, + content: nextQuestion?.content, + required: nextQuestion?.required, + metadata: nextQuestion?.metadata, + choices: nextQuestion?.choices.map((el) => ({ + id: el.id, + text: el.text, + order: el.order, + })), + }, + }); + } +} diff --git a/src/modules/submissions/providers/answer/answer-submission.provider.ts b/src/modules/submissions/providers/answer/answer-submission.provider.ts new file mode 100644 index 0000000..bc21cbc --- /dev/null +++ b/src/modules/submissions/providers/answer/answer-submission.provider.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { AnswerSubmissionDTO } from '../../dtos/answer-submission.dto'; +import { User } from '@/modules/users/entities/user.entity'; +import { AnswerAuthenticatedProvider } from './answer-authenticated.provider'; +import { AnswerGuestProvider } from './answer-guest.provider'; + +@Injectable() +export class AnswerSubmissionProvider { + constructor( + /** + * Inject Answer Autenticated Provider + */ + private readonly answerAuthenticatedProvider: AnswerAuthenticatedProvider, + + /** + * Inject Answer Guest Provider + */ + private readonly answerGuestProvider: AnswerGuestProvider, + ) {} + + public async answer( + id: string, + answerSubmissionDto: AnswerSubmissionDTO, + user?: User, + ) { + if (user) { + return await this.answerAuthenticatedProvider.answer( + id, + answerSubmissionDto, + user, + ); + } else { + return await this.answerGuestProvider.answer(id, answerSubmissionDto); + } + } +} diff --git a/src/modules/submissions/providers/calculate-result.provider.ts b/src/modules/submissions/providers/answer/calculate-result.provider.ts similarity index 58% rename from src/modules/submissions/providers/calculate-result.provider.ts rename to src/modules/submissions/providers/answer/calculate-result.provider.ts index 86796b2..eb65318 100644 --- a/src/modules/submissions/providers/calculate-result.provider.ts +++ b/src/modules/submissions/providers/answer/calculate-result.provider.ts @@ -8,14 +8,14 @@ import { } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm'; -import { SubmissionResult } from '../entities/submission-result.entity'; -import { Submission } from '../entities/submission.entity'; +import { SubmissionResult } from '../../entities/submission-result.entity'; +import { Submission } from '../../entities/submission.entity'; import { QuestionType } from '@/modules/questions/enums/question-type.enum'; import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; -import { SubmissionAnswer } from '../entities/submission-answer.entity'; +import { SubmissionAnswer } from '../../entities/submission-answer.entity'; import { Question } from '@/modules/questions/entities/question.entity'; import { ResultRange } from '@/modules/scoring/entities/result-range.entity'; -import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interface'; +import { GuestSubmissionSession } from '../../interfaces/guest-submission-session.interface'; @Injectable() export class CalculateResultProvider { @@ -220,122 +220,3 @@ export class CalculateResultProvider { }; } } - -// import { Injectable } from '@nestjs/common'; -// import { EntityManager, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; -// import { SubmissionResult } from '../entities/submission-result.entity'; -// import { Submission } from '../entities/submission.entity'; -// import { QuestionType } from '@/modules/questions/enums/question-type.enum'; -// import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; -// import { SubmissionAnswer } from '../entities/submission-answer.entity'; -// import { Question } from '@/modules/questions/entities/question.entity'; -// import { ResultRange } from '@/modules/scoring/entities/result-range.entity'; - -// @Injectable() -// export class CalculateResultProvider { -// public async calculateAutenticated( -// submission: Submission, -// manager: EntityManager, -// ) { -// let score = 0; - -// let submissionAnswers: Array = []; - -// try { -// submissionAnswers = await manager.find(SubmissionAnswer, { -// where: { submission: { id: submission.id } }, -// relations: { -// question: { -// rangeRules: true, -// }, -// choice: true, -// }, -// }); -// } catch (err) { -// throw new AppTimeoutException(err); -// } - -// for (const answer of submissionAnswers) { -// 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 -// ) { -// if ( -// answer.question.rangeRules && -// answer.question.rangeRules.length > 0 -// ) { -// answer.question.rangeRules.forEach((rule) => { -// if ( -// answer.numericValue !== undefined && -// answer.numericValue >= rule.min && -// answer.numericValue <= rule.max -// ) { -// score += answer.question.weight * rule.score; -// } -// }); -// } -// } -// } - -// let totalScores = 0; - -// const testQuestions = await manager.find(Question, { -// where: { test: { id: submission.test.id } }, -// relations: { -// choices: true, -// rangeRules: true, -// }, -// }); - -// testQuestions.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 manager.find(ResultRange, { -// where: { -// test: { id: submission.test.id }, -// minScore: LessThanOrEqual(score), -// maxScore: MoreThanOrEqual(score), -// }, -// }); - -// const submissionResult = manager.create(SubmissionResult, { -// submission, -// score, -// probability: Math.ceil((score / totalScores) * 100), -// resultRange: resultRanges.length > 0 ? resultRanges[0] : undefined, -// }); - -// try { -// await manager.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/start-submission.provider.ts similarity index 95% rename from src/modules/submissions/providers/start-submission.provider.ts rename to src/modules/submissions/providers/start/start-submission.provider.ts index 90757e8..0cf9603 100644 --- a/src/modules/submissions/providers/start-submission.provider.ts +++ b/src/modules/submissions/providers/start/start-submission.provider.ts @@ -8,15 +8,15 @@ import { NotFoundException, } from '@nestjs/common'; import { Repository } from 'typeorm'; -import { Submission } from '../entities/submission.entity'; +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 { 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.interface'; +import { GuestSubmissionSession } from '../../interfaces/guest-submission-session.interface'; import { TestStatus } from '@/modules/tests/enums/test-status.enum'; import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity'; import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum'; diff --git a/src/modules/submissions/providers/submissions.service.ts b/src/modules/submissions/providers/submissions.service.ts index cdb309f..12b6d72 100644 --- a/src/modules/submissions/providers/submissions.service.ts +++ b/src/modules/submissions/providers/submissions.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { StartSubmissionProvider } from './start-submission.provider'; +import { StartSubmissionProvider } from './start/start-submission.provider'; import { User } from '@/modules/users/entities/user.entity'; -import { AnswerSubmissionProvider } from './answer-submission.provider'; +import { AnswerSubmissionProvider } from './answer/answer-submission.provider'; import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto'; @Injectable() diff --git a/src/modules/submissions/submissions.module.ts b/src/modules/submissions/submissions.module.ts index 6327f3f..f20dd3f 100644 --- a/src/modules/submissions/submissions.module.ts +++ b/src/modules/submissions/submissions.module.ts @@ -4,14 +4,16 @@ 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 { StartSubmissionProvider } from './providers/start/start-submission.provider'; import { SubmissionsService } from './providers/submissions.service'; import { TestsModule } from '../tests/tests.module'; -import { AnswerSubmissionProvider } from './providers/answer-submission.provider'; +import { AnswerSubmissionProvider } from './providers/answer/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 { CalculateResultProvider } from './providers/answer/calculate-result.provider'; import { ScoringModule } from '../scoring/scoring.module'; +import { AnswerGuestProvider } from './providers/answer/answer-guest.provider'; +import { AnswerAuthenticatedProvider } from './providers/answer/answer-authenticated.provider'; @Module({ imports: [ @@ -24,8 +26,10 @@ import { ScoringModule } from '../scoring/scoring.module'; controllers: [SubmissionsController], providers: [ StartSubmissionProvider, - AnswerSubmissionProvider, CalculateResultProvider, + AnswerAuthenticatedProvider, + AnswerGuestProvider, + AnswerSubmissionProvider, SubmissionsService, ], })