diff --git a/src/modules/submissions/interfaces/guest-submission-answer.interface.ts b/src/modules/submissions/interfaces/guest-submission-answer.interface.ts new file mode 100644 index 0000000..fd8ae0e --- /dev/null +++ b/src/modules/submissions/interfaces/guest-submission-answer.interface.ts @@ -0,0 +1,5 @@ +export interface GuestSubmissionAnswer { + questionId: number; + choiceId?: number; + numericValue?: number; +} diff --git a/src/modules/submissions/interfaces/guest-submission-result.interface.ts b/src/modules/submissions/interfaces/guest-submission-result.interface.ts new file mode 100644 index 0000000..3d08601 --- /dev/null +++ b/src/modules/submissions/interfaces/guest-submission-result.interface.ts @@ -0,0 +1,6 @@ +export interface GuestSubmissionResult { + score: number; + probability: number; + title: string; + description: string; +} diff --git a/src/modules/submissions/interfaces/guest-submission-session.interface.ts b/src/modules/submissions/interfaces/guest-submission-session.interface.ts new file mode 100644 index 0000000..100c259 --- /dev/null +++ b/src/modules/submissions/interfaces/guest-submission-session.interface.ts @@ -0,0 +1,9 @@ +import { GuestSubmissionAnswer } from './guest-submission-answer.interface'; +import { GuestSubmissionResult } from './guest-submission-result.interface'; + +export interface GuestSubmissionSession { + testId: number; + currentQuestionId: number | null; + answers: Array; + result?: GuestSubmissionResult; +} diff --git a/src/modules/submissions/interfaces/guest-submission-session.interfaces.ts b/src/modules/submissions/interfaces/guest-submission-session.interfaces.ts deleted file mode 100644 index d7baf31..0000000 --- a/src/modules/submissions/interfaces/guest-submission-session.interfaces.ts +++ /dev/null @@ -1,9 +0,0 @@ -export interface GuestSubmissionSession { - testId: number; - currentQuestionId: 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 index f4d87dc..7005c2e 100644 --- a/src/modules/submissions/providers/answer-submission.provider.ts +++ b/src/modules/submissions/providers/answer-submission.provider.ts @@ -8,19 +8,19 @@ 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 { 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 { QuestionsService } from '@/modules/questions/providers/questions.service'; 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 { @@ -49,9 +49,10 @@ export class AnswerSubmissionProvider { private readonly submissionResultRepository: Repository, /** - * Inject Questions Service + * Inject Questions Repository */ - private readonly questionsService: QuestionsService, + @InjectRepository(Question) + private readonly questionsRepository: Repository, /** * Inject CalculateResult Provider @@ -200,10 +201,11 @@ export class AnswerSubmissionProvider { { currentQuestion: null, status: SubmissionStatus.COMPLETED }, ); - const result = await this.calculateResultProvider.calculate( - submission, - manager, - ); + const result = + await this.calculateResultProvider.calculateAuthenticated( + submission, + manager, + ); return { isCompleted: true, @@ -263,6 +265,95 @@ export class AnswerSubmissionProvider { `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; } diff --git a/src/modules/submissions/providers/calculate-result.provider.ts b/src/modules/submissions/providers/calculate-result.provider.ts index d0a2e85..86796b2 100644 --- a/src/modules/submissions/providers/calculate-result.provider.ts +++ b/src/modules/submissions/providers/calculate-result.provider.ts @@ -1,5 +1,13 @@ import { Injectable } from '@nestjs/common'; -import { EntityManager, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; +import { + EntityManager, + In, + LessThanOrEqual, + MoreThanOrEqual, + Repository, +} from 'typeorm'; +import { InjectRepository } from '@nestjs/typeorm'; + import { SubmissionResult } from '../entities/submission-result.entity'; import { Submission } from '../entities/submission.entity'; import { QuestionType } from '@/modules/questions/enums/question-type.enum'; @@ -7,13 +15,29 @@ 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'; +import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interface'; @Injectable() export class CalculateResultProvider { - public async calculate(submission: Submission, manager: EntityManager) { - let score = 0; + constructor( + /** + * Inject Question Repository + */ + @InjectRepository(Question) + private readonly questionRepository: Repository, - let submissionAnswers: Array = []; + /** + * Inject Result Range Repository + */ + @InjectRepository(ResultRange) + private readonly resultRangeRepository: Repository, + ) {} + + public async calculateAuthenticated( + submission: Submission, + manager: EntityManager, + ) { + let submissionAnswers: SubmissionAnswer[] = []; try { submissionAnswers = await manager.find(SubmissionAnswer, { @@ -29,42 +53,145 @@ export class CalculateResultProvider { 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; + const result = await this.calculateFromAnswers( + submissionAnswers.map((answer) => ({ + questionId: answer.question.id, + question: answer.question, + choiceId: answer.choice?.id, + choice: answer.choice, + numericValue: answer.numericValue, + })), + async (questionIds) => + manager.find(Question, { + where: { id: In(questionIds) }, + relations: { + choices: true, + rangeRules: true, + }, + }), + async (score) => + manager.find(ResultRange, { + where: { + test: { id: submission.test.id }, + minScore: LessThanOrEqual(score), + maxScore: MoreThanOrEqual(score), + }, + }), + ); + + const submissionResult = manager.create(SubmissionResult, { + submission, + score: result.score, + probability: result.probability, + resultRange: result.resultRange, + }); + + try { + await manager.save(submissionResult); + } catch (err) { + throw new AppTimeoutException(err); + } + + return { + score: result.score, + probability: result.probability, + title: result.resultRange?.title, + description: result.resultRange?.description, + }; + } + + public async calculateGuest(submission: GuestSubmissionSession) { + const result = await this.calculateFromAnswers( + submission.answers.map((answer) => ({ + questionId: answer.questionId, + choiceId: answer.choiceId, + numericValue: answer.numericValue, + })), + async (questionIds) => + this.questionRepository.find({ + where: { id: In(questionIds) }, + relations: { + choices: true, + rangeRules: true, + }, + }), + async (score) => + this.resultRangeRepository.find({ + where: { + test: { id: submission.testId }, + minScore: LessThanOrEqual(score), + maxScore: MoreThanOrEqual(score), + }, + }), + ); + + submission.result = { + score: result.score, + probability: result.probability, + title: result.resultRange?.title || '', + description: result.resultRange?.description || '', + }; + + return submission.result; + } + + private async calculateFromAnswers( + answers: Array<{ + questionId: number; + question?: Question; + choiceId?: number; + choice?: { id: number; score: number }; + numericValue?: number; + }>, + loadQuestions: (questionIds: number[]) => Promise, + loadResultRanges: (score: number) => Promise, + ) { + let score = 0; + + const questionIds = answers.map((a) => a.questionId); + + const questions = await loadQuestions(questionIds); + + const questionsMap = new Map(); + + questions.forEach((q) => questionsMap.set(q.id, q)); + + for (const answer of answers) { + const question = answer.question ?? questionsMap.get(answer.questionId); + + if (!question) continue; + + if ( + question.type === QuestionType.MULTI_CHOICE && + answer.choiceId !== undefined + ) { + const choice = + answer.choice ?? + question.choices.find((c) => c.id === answer.choiceId); + + if (choice) { + score += question.weight * choice.score; + } } else if ( - answer.question.type === QuestionType.NUMERIC && + 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; - } - }); - } + question.rangeRules?.forEach((rule) => { + if ( + answer.numericValue! >= rule.min && + answer.numericValue! <= rule.max + ) { + score += question.weight * rule.score; + } + }); } } let totalScores = 0; - const testQuestions = await manager.find(Question, { - where: { test: { id: submission.test.id } }, - relations: { - choices: true, - rangeRules: true, - }, - }); + questions.forEach((question) => { + const answerScores: number[] = []; - testQuestions.forEach((question) => { - const answerScores: Array = []; if (question.type === QuestionType.MULTI_CHOICE) { question.choices.forEach((choice) => { answerScores.push(choice.score); @@ -84,32 +211,131 @@ export class CalculateResultProvider { 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); - } + const resultRanges = await loadResultRanges(score); return { score, - probability: submissionResult.probability, - title: submissionResult.resultRange?.title, - description: submissionResult.resultRange?.description, + probability: totalScores > 0 ? Math.ceil((score / totalScores) * 100) : 0, + resultRange: resultRanges.length > 0 ? resultRanges[0] : undefined, }; } } + +// 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-submission.provider.ts index f501342..90757e8 100644 --- a/src/modules/submissions/providers/start-submission.provider.ts +++ b/src/modules/submissions/providers/start-submission.provider.ts @@ -16,7 +16,7 @@ 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'; +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'; @@ -162,15 +162,19 @@ export class StartSubmissionProvider { const guestSubmissionId = randomUUID(); - await this.redisService.set( - `guest_submission:${guestSubmissionId}`, - { - testId: test.id, - currentQuestionId: firstQuestion.id, - answers: [], - }, - 3600, - ); + try { + await this.redisService.set( + `guest_submission:${guestSubmissionId}`, + { + testId: test.id, + currentQuestionId: firstQuestion.id, + answers: [], + }, + 3600, + ); + } catch (err) { + throw new AppTimeoutException(err); + } return new AppResponse({ guestSubmissionId,