diff --git a/src/i18n/en/submissions.json b/src/i18n/en/submissions.json index 350dbd3..9d7182e 100644 --- a/src/i18n/en/submissions.json +++ b/src/i18n/en/submissions.json @@ -11,6 +11,7 @@ "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." + "emptyChoiceId": "Choice Id should be provided as answer.", + "transitionNotFound": "No transition found for the test" } } diff --git a/src/i18n/fa/submissions.json b/src/i18n/fa/submissions.json index d24c15a..8918069 100644 --- a/src/i18n/fa/submissions.json +++ b/src/i18n/fa/submissions.json @@ -11,6 +11,7 @@ "choiceNumericConstraint": "حتما باید یکی از فیلد های شناسه انتخاب و یا مقدار عددی فرستاده شود.", "emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.", "numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.", - "emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود." + "emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود.", + "transitionNotFound": "مسیری برای تست پیدا نشد." } } diff --git a/src/migrations/1786282238898-SubmissionCurrentQuestion.ts b/src/migrations/1786282238898-SubmissionCurrentQuestion.ts new file mode 100644 index 0000000..e118ddb --- /dev/null +++ b/src/migrations/1786282238898-SubmissionCurrentQuestion.ts @@ -0,0 +1,61 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class SubmissionCurrentQuestion1786282238898 implements MigrationInterface { + name = 'SubmissionCurrentQuestion1786282238898'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "public"."unq_test_start"`); + await queryRunner.query(`DROP INDEX "public"."unq_question_end"`); + await queryRunner.query(`DROP INDEX "public"."unq_question_default"`); + await queryRunner.query(`DROP INDEX "public"."unq_question_choice"`); + await queryRunner.query(`DROP INDEX "public"."unq_question_number_range"`); + await queryRunner.query( + `ALTER TABLE "question_transition" DROP CONSTRAINT "ex_question_number_range_overlap"`, + ); + await queryRunner.query( + `ALTER TABLE "submission" RENAME COLUMN "currentQuestionOrder" TO "currentQuestionId"`, + ); + await queryRunner.query( + `ALTER TABLE "submission" ALTER COLUMN "currentQuestionId" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "submission" ALTER COLUMN "currentQuestionId" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "submission" ADD CONSTRAINT "FK_71b22c49b79a4eb32e9b1f05c76" FOREIGN KEY ("currentQuestionId") REFERENCES "question"("id") ON DELETE SET NULL ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "submission" DROP CONSTRAINT "FK_71b22c49b79a4eb32e9b1f05c76"`, + ); + await queryRunner.query( + `ALTER TABLE "submission" ALTER COLUMN "currentQuestionId" SET DEFAULT '1'`, + ); + await queryRunner.query( + `ALTER TABLE "submission" ALTER COLUMN "currentQuestionId" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "submission" RENAME COLUMN "currentQuestionId" TO "currentQuestionOrder"`, + ); + await queryRunner.query( + `ALTER TABLE "question_transition" ADD CONSTRAINT "ex_question_number_range_overlap" EXCLUDE USING gist ("fromQuestionId" WITH =, int4range(((condition ->> 'min'::text))::integer, ((condition ->> 'max'::text))::integer, '[]'::text) WITH &&) WHERE ((type = 'number-range'::question_transition_type_enum))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "unq_question_number_range" ON "question_transition" ("fromQuestionId") WHERE (type = 'number-range'::question_transition_type_enum)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "unq_question_choice" ON "question_transition" ("fromQuestionId") WHERE (type = 'choice'::question_transition_type_enum)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "unq_question_default" ON "question_transition" ("fromQuestionId") WHERE (type = 'default'::question_transition_type_enum)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "unq_question_end" ON "question_transition" ("fromQuestionId") WHERE (type = 'end'::question_transition_type_enum)`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "unq_test_start" ON "question_transition" ("testId") WHERE (type = 'start'::question_transition_type_enum)`, + ); + } +} diff --git a/src/modules/questions/entities/question.entity.ts b/src/modules/questions/entities/question.entity.ts index 225b84a..05ee660 100644 --- a/src/modules/questions/entities/question.entity.ts +++ b/src/modules/questions/entities/question.entity.ts @@ -59,7 +59,7 @@ export class Question { outgoingTransitions!: Array; @OneToMany(() => QuestionTransition, (transition) => transition.toQuestion) - incomingTransition!: Array; + incomingTransitions!: Array; @CreateDateColumn() createdAt!: Date; diff --git a/src/modules/questions/questions.module.ts b/src/modules/questions/questions.module.ts index b81fb83..108cd4e 100644 --- a/src/modules/questions/questions.module.ts +++ b/src/modules/questions/questions.module.ts @@ -60,6 +60,7 @@ import { QuestionTransitionService } from './providers/transition/question-trans ChoicesService, QuestionRangeRuleService, QuestionTransitionService, + TypeOrmModule, ], }) export class QuestionsModule {} diff --git a/src/modules/submissions/entities/submission.entity.ts b/src/modules/submissions/entities/submission.entity.ts index 8fe701a..04258f1 100644 --- a/src/modules/submissions/entities/submission.entity.ts +++ b/src/modules/submissions/entities/submission.entity.ts @@ -12,6 +12,7 @@ import { import { SubmissionStatus } from '../enums/submission-status.enum'; import { SubmissionAnswer } from './submission-answer.entity'; import { SubmissionResult } from './submission-result.entity'; +import { Question } from '@/modules/questions/entities/question.entity'; @Entity() export class Submission { @@ -31,8 +32,8 @@ export class Submission { }) status!: SubmissionStatus; - @Column({ type: 'int', nullable: false, default: 1 }) - currentQuestionOrder!: number; + @ManyToOne(() => Question, { onDelete: 'SET NULL', nullable: true }) + currentQuestion!: Question | null; @OneToMany(() => SubmissionAnswer, (answer) => answer.submission) answers!: Array; diff --git a/src/modules/submissions/interfaces/guest-submission-session.interfaces.ts b/src/modules/submissions/interfaces/guest-submission-session.interfaces.ts index ccdde39..d7baf31 100644 --- a/src/modules/submissions/interfaces/guest-submission-session.interfaces.ts +++ b/src/modules/submissions/interfaces/guest-submission-session.interfaces.ts @@ -1,6 +1,6 @@ export interface GuestSubmissionSession { testId: number; - currentQuestion: number; + currentQuestionId: number; answers: Array<{ questionId: number; choiceId: number; diff --git a/src/modules/submissions/providers/answer-submission.provider.ts b/src/modules/submissions/providers/answer-submission.provider.ts index 46850a7..f4d87dc 100644 --- a/src/modules/submissions/providers/answer-submission.provider.ts +++ b/src/modules/submissions/providers/answer-submission.provider.ts @@ -1,5 +1,5 @@ import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common'; -import { DataSource, In, Repository } from 'typeorm'; +import { DataSource, Repository } from 'typeorm'; import { Submission } from '../entities/submission.entity'; import { InjectRepository } from '@nestjs/typeorm'; import { AnswerSubmissionDTO } from '../dtos/answer-submission.dto'; @@ -17,6 +17,10 @@ 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'; @Injectable() export class AnswerSubmissionProvider { @@ -94,6 +98,10 @@ export class AnswerSubmissionProvider { where: { id: submissionId, user: { id: user.id } }, relations: { test: true, + currentQuestion: { + choices: true, + outgoingTransitions: true, + }, }, }); } catch (err) { @@ -108,23 +116,7 @@ export class AnswerSubmissionProvider { ); } - const [currentQuestion, nextQuestion]: Array = - await this.questionsService.findByOptionsForAdmin({ - where: { - test: { id: submission.test.id }, - order: In([ - submission.currentQuestionOrder, - submission.currentQuestionOrder + 1, - ]), - }, - relations: { - choices: true, - rangeRules: true, - }, - order: { - order: 'ASC', - }, - }); + const currentQuestion = submission.currentQuestion; if (!currentQuestion) { let result: SubmissionResult | null; @@ -163,7 +155,7 @@ export class AnswerSubmissionProvider { if ( currentQuestion.type === QuestionType.MULTI_CHOICE && - answerSubmissionDto.choiceId + answerSubmissionDto.choiceId !== undefined ) { const choice = currentQuestion.choices.find( (el) => el.id === answerSubmissionDto.choiceId, @@ -185,13 +177,29 @@ export class AnswerSubmissionProvider { 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 (!nextQuestion) { + if ( + nextTransition.type === QuestionTransitionType.END || + !nextQuestion + ) { + await manager.update( + Submission, + { id: submission.id }, + { currentQuestion: null, status: SubmissionStatus.COMPLETED }, + ); + const result = await this.calculateResultProvider.calculate( submission, manager, @@ -204,11 +212,10 @@ export class AnswerSubmissionProvider { }; } - await manager.increment( + await manager.update( Submission, { id: submission.id }, - 'currentQuestionOrder', - 1, + { currentQuestion: nextQuestion }, ); return null; @@ -225,11 +232,11 @@ export class AnswerSubmissionProvider { isCompleted: false, // isFinal: , currentQuestion: { - type: nextQuestion.type, - content: nextQuestion.content, - required: nextQuestion.required, - metadata: nextQuestion.metadata, - choices: nextQuestion.choices.map((el) => ({ + 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, @@ -296,4 +303,59 @@ export class AnswerSubmissionProvider { } } } + + 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/start-submission.provider.ts b/src/modules/submissions/providers/start-submission.provider.ts index 2c67799..f501342 100644 --- a/src/modules/submissions/providers/start-submission.provider.ts +++ b/src/modules/submissions/providers/start-submission.provider.ts @@ -5,6 +5,7 @@ import { BadRequestException, ForbiddenException, Injectable, + NotFoundException, } from '@nestjs/common'; import { Repository } from 'typeorm'; import { Submission } from '../entities/submission.entity'; @@ -17,7 +18,8 @@ import { randomUUID } from 'crypto'; import { RedisService } from '@/common/modules/redis/providers/redis.service'; import { GuestSubmissionSession } from '../interfaces/guest-submission-session.interfaces'; import { TestStatus } from '@/modules/tests/enums/test-status.enum'; -import { QuestionTransitionService } from '@/modules/questions/providers/transition/question-transition.service'; +import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity'; +import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum'; @Injectable() export class StartSubmissionProvider { @@ -39,42 +41,14 @@ export class StartSubmissionProvider { private readonly redisService: RedisService, /** - * Inject Transitions Service + * Inject QuestionTransition Repository */ - private readonly transitionsService: QuestionTransitionService, + @InjectRepository(QuestionTransition) + private readonly transitionsRepository: Repository, ) {} 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, - }, - }, - }, - }); + const test = await this.testsService.adminGetOne(testId); if (!test.isActive || test.status !== TestStatus.PUBLISHED) { throw new ForbiddenException('tests.errors.notActiveTest'); @@ -91,6 +65,54 @@ export class StartSubmissionProvider { return await this.startGuest(test); } + private async getFirstQuestion(testId: number) { + let transition: QuestionTransition | null = null; + + try { + transition = await this.transitionsRepository.findOne({ + where: { + test: { id: testId }, + type: QuestionTransitionType.START, + }, + relations: { + toQuestion: { + choices: true, + rangeRules: true, + }, + }, + order: { + toQuestion: { + choices: { + order: 'ASC', + }, + }, + }, + select: { + toQuestion: { + id: true, + type: true, + content: true, + required: true, + metadata: true, + choices: { + id: true, + order: true, + text: true, + }, + }, + }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!transition || !transition.toQuestion) { + throw new NotFoundException('submissions.errors.transitionNotFound'); + } + + return transition.toQuestion; + } + private async startAuthenticated(test: Test, user: User) { let submission: Submission | null = null; @@ -113,10 +135,13 @@ export class StartSubmissionProvider { throw new BadRequestException('submissions.errors.alreadyTookTest'); } + const firstQuestion = await this.getFirstQuestion(test.id); + if (!submission) { submission = this.submissionsRepository.create({ test, user: user || undefined, + currentQuestion: firstQuestion, }); try { @@ -126,22 +151,22 @@ export class StartSubmissionProvider { } } - // const startTransition = await this.transitionsService. - return new AppResponse({ submissionId: submission.id, - question: test.questions.length > 0 ? test.questions[0] : null, + question: firstQuestion, }); } private async startGuest(test: Test) { + const firstQuestion = await this.getFirstQuestion(test.id); + const guestSubmissionId = randomUUID(); await this.redisService.set( `guest_submission:${guestSubmissionId}`, { testId: test.id, - currentQuestion: 0, + currentQuestionId: firstQuestion.id, answers: [], }, 3600, @@ -149,7 +174,7 @@ export class StartSubmissionProvider { return new AppResponse({ guestSubmissionId, - question: test.questions[0], + question: firstQuestion, }); } }