From 2e146e52b58ab61fbfcc1bd53070126b644c3559 Mon Sep 17 00:00:00 2001 From: radmehr Date: Fri, 21 Aug 2026 10:25:33 +0330 Subject: [PATCH] feat: add multi choice submission logic --- src/i18n/en/submissions.json | 4 +- src/i18n/fa/submissions.json | 4 +- ...1786282238898-SubmissionCurrentQuestion.ts | 16 ++- .../1787294625853-MultiChoiceSubmission.ts | 107 ++++++++++++++++++ ...94625900-MultiChoiceTransitionUnqIndex.ts} | 0 .../entities/submission-answer.entity.ts | 6 + .../check-answer-correctness.helper.ts | 21 +++- .../helpers/get-next-transition.helper.ts | 53 +++++++++ .../guest-submission-answer.interface.ts | 1 + .../answer/answer-authenticated.provider.ts | 9 ++ .../providers/answer/answer-guest.provider.ts | 11 +- .../answer/calculate-result.provider.ts | 52 +++++++-- 12 files changed, 262 insertions(+), 22 deletions(-) create mode 100644 src/migrations/1787294625853-MultiChoiceSubmission.ts rename src/migrations/{1786856474000-MultiChoiceTransitionUnqIndex.ts => 1787294625900-MultiChoiceTransitionUnqIndex.ts} (100%) diff --git a/src/i18n/en/submissions.json b/src/i18n/en/submissions.json index 7a19cb8..cf6b749 100644 --- a/src/i18n/en/submissions.json +++ b/src/i18n/en/submissions.json @@ -4,7 +4,8 @@ "questionId": "Question's Id", "submission": "Submission", "submissionId": "Submission's Id", - "choiceId": "Choice's Id" + "choiceId": "Choice's Id", + "choiceIds": "Choice Ids list" }, "errors": { "alreadyTookTest": "You already took this test.", @@ -12,6 +13,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.", + "emptyChoiceIds": "Choice Ids should be provided as answer.", "transitionNotFound": "No transition found for the test", "uniqueSubmissionQuestion": "Question's Answer already exists." } diff --git a/src/i18n/fa/submissions.json b/src/i18n/fa/submissions.json index f3c7b3a..5779e2a 100644 --- a/src/i18n/fa/submissions.json +++ b/src/i18n/fa/submissions.json @@ -4,7 +4,8 @@ "questionId": "شناسه سوال", "submission": "Submission", "submissionId": "شناسه Submission", - "choiceId": "شناسه گزینه" + "choiceId": "شناسه گزینه", + "choiceIds": "لیست شناسه گزینه ها" }, "errors": { "alreadyTookTest": "قبلا این آزمون را انجام داده اید.", @@ -12,6 +13,7 @@ "emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.", "numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.", "emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود.", + "emptyChoiceIds": "لیستی از شناسه گزینه ها باید به عنوان پاسخ مشخص شود.", "transitionNotFound": "مسیری برای تست پیدا نشد.", "uniqueSubmissionQuestion": "این سوال قبلا پاسخ داده شده است." } diff --git a/src/migrations/1786282238898-SubmissionCurrentQuestion.ts b/src/migrations/1786282238898-SubmissionCurrentQuestion.ts index e118ddb..bfffb03 100644 --- a/src/migrations/1786282238898-SubmissionCurrentQuestion.ts +++ b/src/migrations/1786282238898-SubmissionCurrentQuestion.ts @@ -4,11 +4,17 @@ export class SubmissionCurrentQuestion1786282238898 implements MigrationInterfac 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(`DROP INDEX IF EXISTS "public"."unq_test_start"`); + await queryRunner.query(`DROP INDEX IF EXISTS "public"."unq_question_end"`); + await queryRunner.query( + `DROP INDEX IF EXISTS "public"."unq_question_default"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "public"."unq_question_choice"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "public"."unq_question_number_range"`, + ); await queryRunner.query( `ALTER TABLE "question_transition" DROP CONSTRAINT "ex_question_number_range_overlap"`, ); diff --git a/src/migrations/1787294625853-MultiChoiceSubmission.ts b/src/migrations/1787294625853-MultiChoiceSubmission.ts new file mode 100644 index 0000000..ef27034 --- /dev/null +++ b/src/migrations/1787294625853-MultiChoiceSubmission.ts @@ -0,0 +1,107 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class MultiChoiceSubmission1787294625853 implements MigrationInterface { + name = 'MultiChoiceSubmission1787294625853'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_ea78aac2e76e03f1c2fc720f1d"`, + ); + await queryRunner.query( + `CREATE TABLE "submission_answer_choices_choice" ("submissionAnswerId" integer NOT NULL, "choiceId" integer NOT NULL, CONSTRAINT "PK_d3154f459415dc8f97a4bb10a1e" PRIMARY KEY ("submissionAnswerId", "choiceId"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6f1902623cd8b3b52a5688a949" ON "submission_answer_choices_choice" ("submissionAnswerId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_2e699b5f339fb37917549c2fbf" ON "submission_answer_choices_choice" ("choiceId") `, + ); + await queryRunner.query( + `ALTER TYPE "public"."question_transition_type_enum" RENAME TO "question_transition_type_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."question_transition_type_enum" AS ENUM('start', 'default', 'choice', 'multi-choice', 'number-range', 'end')`, + ); + await queryRunner.query( + `ALTER TABLE "question_transition" ALTER COLUMN "type" TYPE "public"."question_transition_type_enum" USING "type"::"text"::"public"."question_transition_type_enum"`, + ); + await queryRunner.query( + `DROP TYPE "public"."question_transition_type_enum_old"`, + ); + await queryRunner.query( + `ALTER TYPE "public"."question_type_enum" RENAME TO "question_type_enum_old"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."question_type_enum" AS ENUM('single-choice', 'multi-choice', 'numeric', 'text')`, + ); + await queryRunner.query( + `ALTER TABLE "question" ALTER COLUMN "type" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "question" ALTER COLUMN "type" TYPE "public"."question_type_enum" USING "type"::"text"::"public"."question_type_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "question" ALTER COLUMN "type" SET DEFAULT 'single-choice'`, + ); + await queryRunner.query(`DROP TYPE "public"."question_type_enum_old"`); + await queryRunner.query( + `CREATE UNIQUE INDEX "UNQ_SUBMISSION_QUESTION_IDX" ON "submission_answer" ("submissionId", "questionId") `, + ); + await queryRunner.query( + `ALTER TABLE "submission_answer_choices_choice" ADD CONSTRAINT "FK_6f1902623cd8b3b52a5688a949b" FOREIGN KEY ("submissionAnswerId") REFERENCES "submission_answer"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + await queryRunner.query( + `ALTER TABLE "submission_answer_choices_choice" ADD CONSTRAINT "FK_2e699b5f339fb37917549c2fbfc" FOREIGN KEY ("choiceId") REFERENCES "choice"("id") ON DELETE CASCADE ON UPDATE CASCADE`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "submission_answer_choices_choice" DROP CONSTRAINT "FK_2e699b5f339fb37917549c2fbfc"`, + ); + await queryRunner.query( + `ALTER TABLE "submission_answer_choices_choice" DROP CONSTRAINT "FK_6f1902623cd8b3b52a5688a949b"`, + ); + await queryRunner.query( + `DROP INDEX "public"."UNQ_SUBMISSION_QUESTION_IDX"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."question_type_enum_old" AS ENUM('multi-choice', 'numeric', 'text')`, + ); + await queryRunner.query( + `ALTER TABLE "question" ALTER COLUMN "type" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "question" ALTER COLUMN "type" TYPE "public"."question_type_enum_old" USING "type"::"text"::"public"."question_type_enum_old"`, + ); + await queryRunner.query( + `ALTER TABLE "question" ALTER COLUMN "type" SET DEFAULT 'multi-choice'`, + ); + await queryRunner.query(`DROP TYPE "public"."question_type_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."question_type_enum_old" RENAME TO "question_type_enum"`, + ); + await queryRunner.query( + `CREATE TYPE "public"."question_transition_type_enum_old" AS ENUM('start', 'default', 'choice', 'number-range', 'end')`, + ); + await queryRunner.query( + `ALTER TABLE "question_transition" ALTER COLUMN "type" TYPE "public"."question_transition_type_enum_old" USING "type"::"text"::"public"."question_transition_type_enum_old"`, + ); + await queryRunner.query( + `DROP TYPE "public"."question_transition_type_enum"`, + ); + await queryRunner.query( + `ALTER TYPE "public"."question_transition_type_enum_old" RENAME TO "question_transition_type_enum"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_2e699b5f339fb37917549c2fbf"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_6f1902623cd8b3b52a5688a949"`, + ); + await queryRunner.query(`DROP TABLE "submission_answer_choices_choice"`); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_ea78aac2e76e03f1c2fc720f1d" ON "submission_answer" ("questionId", "submissionId") `, + ); + } +} diff --git a/src/migrations/1786856474000-MultiChoiceTransitionUnqIndex.ts b/src/migrations/1787294625900-MultiChoiceTransitionUnqIndex.ts similarity index 100% rename from src/migrations/1786856474000-MultiChoiceTransitionUnqIndex.ts rename to src/migrations/1787294625900-MultiChoiceTransitionUnqIndex.ts diff --git a/src/modules/submissions/entities/submission-answer.entity.ts b/src/modules/submissions/entities/submission-answer.entity.ts index 0bd6ca6..d9a8505 100644 --- a/src/modules/submissions/entities/submission-answer.entity.ts +++ b/src/modules/submissions/entities/submission-answer.entity.ts @@ -2,6 +2,8 @@ import { Column, Entity, Index, + JoinTable, + ManyToMany, ManyToOne, PrimaryGeneratedColumn, } from 'typeorm'; @@ -29,6 +31,10 @@ export class SubmissionAnswer { @ManyToOne(() => Choice, { nullable: true, onDelete: 'CASCADE' }) choice?: Choice; + @ManyToMany(() => Choice) + @JoinTable() + choices?: Array; + @Column({ type: 'int', nullable: true }) numericValue?: number; diff --git a/src/modules/submissions/helpers/check-answer-correctness.helper.ts b/src/modules/submissions/helpers/check-answer-correctness.helper.ts index 1750161..f1833f8 100644 --- a/src/modules/submissions/helpers/check-answer-correctness.helper.ts +++ b/src/modules/submissions/helpers/check-answer-correctness.helper.ts @@ -11,7 +11,7 @@ export const checkAnswerCorrectness = ( ) => { if (!answerSubmissionDto) throw new BadRequestException(); - const { choiceId, numericValue } = answerSubmissionDto; + const { choiceId, choiceIds, numericValue } = answerSubmissionDto; if (question.type === QuestionType.NUMERIC) { if (numericValue === undefined) { @@ -28,15 +28,32 @@ export const checkAnswerCorrectness = ( } } + const questionChoiceIds = question.choices.map((el) => el.id); + if (question.type === QuestionType.SINGLE_CHOICE) { if (choiceId === undefined || choiceId === null) { throw new BadRequestException('submissions.errors.emptyChoiceId'); } - if (!question.choices?.some((c) => c.id === choiceId)) { + if (!questionChoiceIds.some((c) => c === choiceId)) { throw new AppException('validation.wrongField', HttpStatus.BAD_REQUEST, { field: '$t(submissions.fields.choiceId)', }); } } + + if (question.type === QuestionType.MULTI_CHOICE) { + if (choiceIds === undefined || choiceIds === null) { + throw new BadRequestException('submissions.errors.emptyChoiceId'); + } + + if ( + choiceIds.length > 0 && + !choiceIds.every((el) => questionChoiceIds.includes(el)) + ) { + throw new AppException('validation.wrongField', HttpStatus.BAD_REQUEST, { + field: '$t(submissions.fields.choiceIds)', + }); + } + } }; diff --git a/src/modules/submissions/helpers/get-next-transition.helper.ts b/src/modules/submissions/helpers/get-next-transition.helper.ts index e328352..941a52a 100644 --- a/src/modules/submissions/helpers/get-next-transition.helper.ts +++ b/src/modules/submissions/helpers/get-next-transition.helper.ts @@ -6,6 +6,8 @@ import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-cho import { NumericCondition } from '@/modules/questions/interfaces/numeric-condition.interface'; import { AppException } from '@/common/exceptions/app.exception'; import { HttpStatus } from '@nestjs/common'; +import { MultiChoiceCondition } from '@/modules/questions/interfaces/multi-choice-condition.interface'; +import { MultiChoiceTConditionOperator } from '@/modules/questions/enums/multi-choice-tcondition-operator.enum'; export const getNextTransition = ( question: Question, @@ -31,6 +33,57 @@ export const getNextTransition = ( if (transition) return transition; } + if ( + question.type === QuestionType.MULTI_CHOICE && + answerSubmissionDto.choiceIds !== undefined + ) { + const selectedChoiceIds = new Set(answerSubmissionDto.choiceIds); + + const transition = transitions.find((t) => { + if (t.type !== QuestionTransitionType.MULTI_CHOICE) { + return false; + } + + const condition = t.condition as MultiChoiceCondition; + + if ( + !condition || + !Array.isArray(condition.choiceIds) || + !condition.operator + ) { + return false; + } + + const conditionChoiceIds = new Set(condition.choiceIds); + + switch (condition.operator) { + case MultiChoiceTConditionOperator.ANY: + return [...conditionChoiceIds].some((id) => + selectedChoiceIds.has(id), + ); + + case MultiChoiceTConditionOperator.ALL: + return [...conditionChoiceIds].every((id) => + selectedChoiceIds.has(id), + ); + + case MultiChoiceTConditionOperator.EXACT: + if (selectedChoiceIds.size !== conditionChoiceIds.size) { + return false; + } + + return [...conditionChoiceIds].every((id) => + selectedChoiceIds.has(id), + ); + + default: + return false; + } + }); + + if (transition) return transition; + } + if ( question.type === QuestionType.NUMERIC && answerSubmissionDto.numericValue !== undefined diff --git a/src/modules/submissions/interfaces/guest-submission-answer.interface.ts b/src/modules/submissions/interfaces/guest-submission-answer.interface.ts index fd8ae0e..77d2085 100644 --- a/src/modules/submissions/interfaces/guest-submission-answer.interface.ts +++ b/src/modules/submissions/interfaces/guest-submission-answer.interface.ts @@ -1,5 +1,6 @@ export interface GuestSubmissionAnswer { questionId: number; choiceId?: number; + choiceIds?: Array; numericValue?: number; } diff --git a/src/modules/submissions/providers/answer/answer-authenticated.provider.ts b/src/modules/submissions/providers/answer/answer-authenticated.provider.ts index 60594bf..240aa18 100644 --- a/src/modules/submissions/providers/answer/answer-authenticated.provider.ts +++ b/src/modules/submissions/providers/answer/answer-authenticated.provider.ts @@ -147,6 +147,15 @@ export class AnswerAuthenticatedProvider { } submissionAnswer.choice = choice; + } else if ( + currentQuestion.type === QuestionType.MULTI_CHOICE && + answerSubmissionDto.choiceIds !== undefined + ) { + const choices = currentQuestion.choices.filter((el) => + answerSubmissionDto.choiceIds?.includes(el.id), + ); + + submissionAnswer.choices = choices; } else if ( currentQuestion.type === QuestionType.NUMERIC && answerSubmissionDto.numericValue !== undefined diff --git a/src/modules/submissions/providers/answer/answer-guest.provider.ts b/src/modules/submissions/providers/answer/answer-guest.provider.ts index 0e256fa..567a977 100644 --- a/src/modules/submissions/providers/answer/answer-guest.provider.ts +++ b/src/modules/submissions/providers/answer/answer-guest.provider.ts @@ -56,7 +56,7 @@ export class AnswerGuestProvider { } try { - return this.answerGuest(id, answerSubmissionDto); + return await this.answerGuest(id, answerSubmissionDto); } finally { await this.redisService.releaseLock(lockKey, lockToken); } @@ -142,6 +142,15 @@ export class AnswerGuestProvider { } newAnswer.choiceId = choice.id; + } else if ( + currentQuestion.type === QuestionType.MULTI_CHOICE && + answerSubmissionDto.choiceIds !== undefined + ) { + const choiceIds = currentQuestion.choices + .filter((el) => answerSubmissionDto.choiceIds?.includes(el.id)) + .map((el) => el.id); + + newAnswer.choiceIds = [...new Set(choiceIds)].sort((a, b) => a - b); } else if ( currentQuestion.type === QuestionType.NUMERIC && answerSubmissionDto.numericValue !== undefined diff --git a/src/modules/submissions/providers/answer/calculate-result.provider.ts b/src/modules/submissions/providers/answer/calculate-result.provider.ts index d37c931..c5e5826 100644 --- a/src/modules/submissions/providers/answer/calculate-result.provider.ts +++ b/src/modules/submissions/providers/answer/calculate-result.provider.ts @@ -47,6 +47,7 @@ export class CalculateResultProvider { rangeRules: true, }, choice: true, + choices: true, }, }); } catch (err) { @@ -59,6 +60,8 @@ export class CalculateResultProvider { question: answer.question, choiceId: answer.choice?.id, choice: answer.choice, + choiceIds: answer.choices?.map((choice) => choice.id), + choices: answer.choices, numericValue: answer.numericValue, })), async (questionIds) => @@ -105,6 +108,7 @@ export class CalculateResultProvider { submission.answers.map((answer) => ({ questionId: answer.questionId, choiceId: answer.choiceId, + choiceIds: answer.choiceIds, numericValue: answer.numericValue, })), async (questionIds) => @@ -141,6 +145,8 @@ export class CalculateResultProvider { question?: Question; choiceId?: number; choice?: { id: number; score: number }; + choiceIds?: Array; + choices?: Array<{ id: number; score: number }>; numericValue?: number; }>, loadQuestions: (questionIds: number[]) => Promise, @@ -172,6 +178,22 @@ export class CalculateResultProvider { if (choice) { score += question.weight * choice.score; } + } else if ( + question.type === QuestionType.MULTI_CHOICE && + answer.choiceIds !== undefined + ) { + const selectedChoiceIds = new Set(answer.choiceIds); + + const selectedChoices = + answer.choices ?? + question.choices.filter((choice) => selectedChoiceIds.has(choice.id)); + + const multiChoiceScore = selectedChoices.reduce( + (sum, choice) => sum + choice.score, + 0, + ); + + score += question.weight * multiChoiceScore; } else if ( question.type === QuestionType.NUMERIC && answer.numericValue !== undefined @@ -190,25 +212,31 @@ export class CalculateResultProvider { let totalScores = 0; questions.forEach((question) => { - const answerScores: number[] = []; - if (question.type === QuestionType.SINGLE_CHOICE) { - question.choices.forEach((choice) => { - answerScores.push(choice.score); - }); + const maxScore = + question.choices.length > 0 + ? Math.max(...question.choices.map((choice) => choice.score)) + : 0; + + totalScores += question.weight * maxScore; + } else if (question.type === QuestionType.MULTI_CHOICE) { + const maxScore = question.choices.reduce( + (sum, choice) => sum + choice.score, + 0, + ); + + totalScores += question.weight * maxScore; } else if ( question.type === QuestionType.NUMERIC && question.rangeRules && question.rangeRules.length > 0 ) { - question.rangeRules.forEach((rule) => { - answerScores.push(rule.score); - }); + const maxScore = Math.max( + ...question.rangeRules.map((rule) => rule.score), + ); + + totalScores += question.weight * maxScore; } - - const maxScore = answerScores.length > 0 ? Math.max(...answerScores) : 0; - - totalScores += question.weight * maxScore; }); const resultRanges = await loadResultRanges(score);