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
@@ -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);