import { BadRequestException, 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'; import { QuestionsService } from '@/modules/questions/providers/questions.service'; import { SubmissionResult } from '../entities/submission-result.entity'; @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 Service */ private readonly questionsService: QuestionsService, /** * 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, user: { id: user.id } }, relations: { test: true, }, }); } 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, // ); const currentQuestion: Question | null = await this.questionsService.findOneForAdmin({ where: { test: { id: submission.test.id }, order: submission.currentQuestionOrder, }, relations: { choices: true, rangeRules: true, }, }); 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 ) { 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 ( currentQuestion.type === QuestionType.NUMERIC && answerSubmissionDto.numericValue ) { submissionAnswer.numericValue = answerSubmissionDto.numericValue; } try { await this.submissionAnswerRepository.save(submissionAnswer); } catch (err) { throw new AppTimeoutException(err); } const nextQuestion: Question | null = await this.questionsService.findOneForAdmin({ where: { test: { id: submission.test.id }, order: submission.currentQuestionOrder + 1, }, relations: { choices: true, rangeRules: true, }, }); if (!nextQuestion) { const result = await this.calculateResultProvider.calculate(submission); return new AppResponse({ isCompleted: true, currentQuestion: null, result, }); } return new AppResponse({ isCompleted: false, // isFinal: , 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)' }, ); } } } }