feat: add multi choice submission logic

This commit is contained in:
2026-08-21 10:25:33 +03:30
parent ca88e6994e
commit 2e146e52b5
12 changed files with 262 additions and 22 deletions
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -4,7 +4,8 @@
"questionId": "شناسه سوال",
"submission": "Submission",
"submissionId": "شناسه Submission",
"choiceId": "شناسه گزینه"
"choiceId": "شناسه گزینه",
"choiceIds": "لیست شناسه گزینه ها"
},
"errors": {
"alreadyTookTest": "قبلا این آزمون را انجام داده اید.",
@@ -12,6 +13,7 @@
"emptyNumericValue": "یک مقدار عددی باید به عنوان پاسخ سوال مشخص شود.",
"numericValueRange": "مقدار عددی خارج از بازه مشخص شده است.",
"emptyChoiceId": "شناسه گزینه باید به عنوان پاسخ مشخص شود.",
"emptyChoiceIds": "لیستی از شناسه گزینه ها باید به عنوان پاسخ مشخص شود.",
"transitionNotFound": "مسیری برای تست پیدا نشد.",
"uniqueSubmissionQuestion": "این سوال قبلا پاسخ داده شده است."
}
@@ -4,11 +4,17 @@ export class SubmissionCurrentQuestion1786282238898 implements MigrationInterfac
name = 'SubmissionCurrentQuestion1786282238898';
public async up(queryRunner: QueryRunner): Promise<void> {
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"`,
);
@@ -0,0 +1,107 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MultiChoiceSubmission1787294625853 implements MigrationInterface {
name = 'MultiChoiceSubmission1787294625853';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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") `,
);
}
}
@@ -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<Choice>;
@Column({ type: 'int', nullable: true })
numericValue?: number;
@@ -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)',
});
}
}
};
@@ -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
@@ -1,5 +1,6 @@
export interface GuestSubmissionAnswer {
questionId: number;
choiceId?: number;
choiceIds?: Array<number>;
numericValue?: number;
}
@@ -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
@@ -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
@@ -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<number>;
choices?: Array<{ id: number; score: number }>;
numericValue?: number;
}>,
loadQuestions: (questionIds: number[]) => Promise<Question[]>,
@@ -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);