feat: seperate single and multi choice questions

This commit is contained in:
2026-08-17 09:43:56 +03:30
parent f6a57454f4
commit ca8d02c531
27 changed files with 347 additions and 28 deletions
+4
View File
@@ -31,8 +31,12 @@
"endMustNotHaveCondition": "End must not have condition.",
"invalidTransition": "Invalid Transition!",
"defaultMustNotHaveCondition": "Default must not have condition.",
"fromQuestionMustBeSingleChoice": "fromQuestion must be single choice.",
"fromQuestionMustBeMultiChoice": "fromQuestion must be multi choice.",
"invalidChoiceCondition": "Invalid choice condition!",
"invalidMultiChoiceCondition": "Invalid multi choice condition!",
"invalidMultiChoiceOperator": "Invalid multi choice operator!",
"duplicateChoiceIds": "Duplicate choice Ids!",
"choiceNotBelongToQuestion": "Choice does not belong to the question.",
"fromQuestionMustBeNumeric": "fromQuestion must be numeric.",
"invalidNumberRange": "Invalid number range!",
+5 -1
View File
@@ -31,8 +31,12 @@
"endMustNotHaveCondition": "حالت پایان نباید شرط داشته باشد.",
"invalidTransition": "مسیر اشتباه است!",
"defaultMustNotHaveCondition": "حالت پیشفرض نباید شرط داشته باشد.",
"fromQuestionMustBeMultiChoice": "سوال مبدا باید چند گزینه ای باشد.",
"fromQuestionMustBeSingleChoice": "سوال مبدا باید تک انتخابی باشد.",
"fromQuestionMustBeMultiChoice": "سوال مبدا باید چند انتخابی باشد.",
"invalidChoiceCondition": "شرط گزینه اشتباه است!",
"invalidMultiChoiceCondition": "شرط گزینه ها اشتباه است!",
"invalidMultiChoiceOperator": "عملگر شرط اشتباه است!",
"duplicateChoiceIds": "شناسه گزینه ها تکراری است!",
"choiceNotBelongToQuestion": "گزینه متعلق به سوال نیست.",
"fromQuestionMustBeNumeric": "سوال مبدا باید از نوع عددی باشد.",
"invalidNumberRange": "بازه عددی اشتباه است!",
@@ -0,0 +1,26 @@
import { UNIQUE_QUESTION_MULTI_CHOICE } from '@/modules/questions/constants';
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MultiChoiceTransitionUniqueIndex1786856474000 implements MigrationInterface {
name = 'AddMultiChoiceTransitionUniqueIndex1750000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE UNIQUE INDEX ${UNIQUE_QUESTION_MULTI_CHOICE}
ON "question_transition" (
"fromQuestionId",
"type",
(("condition" ->> 'operator')),
(("condition" -> 'choiceIds'))
)
WHERE "type" = '${QuestionTransitionType.MULTI_CHOICE}'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DROP INDEX ${UNIQUE_QUESTION_MULTI_CHOICE}
`);
}
}
+1
View File
@@ -8,3 +8,4 @@ export const UNIQUE_QUESTION_END = 'UNQ_QUESTION_END';
export const UNIQUE_TEST_START = 'UNQ_TEST_START';
export const EX_QUESTION_NUMBER_RANGE_OVERLAP =
'EX_QUESTION_NUMBER_RANGE_OVERLAP';
export const UNIQUE_QUESTION_MULTI_CHOICE = 'UNQ_QUESTION_MULTI_CHOICE';
@@ -8,7 +8,7 @@ import {
Validate,
} from 'class-validator';
import { QuestionType } from '../enums/question-type.enum';
import { MultiChoiceMetadataDTO } from './multi-choice-metadata.dto';
import { SingleChoiceMetadataDTO } from './single-choice-metadata.dto';
import { NumericMetadataDTO } from './numeric-metadata.dto';
import { QuestionMetadataValidator } from '../validators/question-metadata.validator';
import { i18nValidationMessage as t } from 'nestjs-i18n';
@@ -60,5 +60,5 @@ export class CreateQuestionDTO {
@Validate(QuestionMetadataValidator)
@IsOptional()
metadata?: MultiChoiceMetadataDTO | NumericMetadataDTO;
metadata?: SingleChoiceMetadataDTO | NumericMetadataDTO;
}
@@ -1,6 +1,6 @@
import { IsBoolean, IsOptional } from 'class-validator';
export class MultiChoiceMetadataDTO {
export class SingleChoiceMetadataDTO {
@IsBoolean()
@IsOptional()
allowOther?: boolean;
@@ -7,7 +7,7 @@ import {
} from 'class-validator';
import { i18nValidationMessage as t } from 'nestjs-i18n';
import { QuestionType } from '../enums/question-type.enum';
import { MultiChoiceMetadataDTO } from './multi-choice-metadata.dto';
import { SingleChoiceMetadataDTO } from './single-choice-metadata.dto';
import { NumericMetadataDTO } from './numeric-metadata.dto';
export class UpdateQuestionDTO {
@@ -48,5 +48,5 @@ export class UpdateQuestionDTO {
required?: boolean;
@IsOptional()
metadata?: MultiChoiceMetadataDTO | NumericMetadataDTO;
metadata?: SingleChoiceMetadataDTO | NumericMetadataDTO;
}
@@ -29,7 +29,7 @@ export class Question {
@Column({
type: 'enum',
enum: QuestionType,
default: QuestionType.MULTI_CHOICE,
default: QuestionType.SINGLE_CHOICE,
})
type!: QuestionType;
@@ -0,0 +1,5 @@
export enum MultiChoiceTConditionOperator {
ANY = 'any',
ALL = 'all',
EXACT = 'exact',
}
@@ -2,6 +2,7 @@ export enum QuestionTransitionType {
START = 'start',
DEFAULT = 'default',
CHOICE = 'choice',
MULTI_CHOICE = 'multi-choice',
NUMBER_RANGE = 'number-range',
END = 'end',
}
@@ -1,4 +1,5 @@
export enum QuestionType {
SINGLE_CHOICE = 'single-choice',
MULTI_CHOICE = 'multi-choice',
NUMERIC = 'numeric',
TEXT = 'text',
@@ -2,7 +2,7 @@ import { plainToInstance } from 'class-transformer';
import { QuestionType } from '../enums/question-type.enum';
import { QuestionMetadata } from '../types/question-metadata.type';
import { NumericMetadataDTO } from '../dtos/numeric-metadata.dto';
import { MultiChoiceMetadataDTO } from '../dtos/multi-choice-metadata.dto';
import { SingleChoiceMetadataDTO } from '../dtos/single-choice-metadata.dto';
import { validate } from 'class-validator';
import { ValidationError } from '@nestjs/common';
@@ -22,8 +22,8 @@ export const validateMetadata = async (
break;
default:
case QuestionType.MULTI_CHOICE:
validateDto = plainToInstance(MultiChoiceMetadataDTO, metadata);
case QuestionType.SINGLE_CHOICE:
validateDto = plainToInstance(SingleChoiceMetadataDTO, metadata);
break;
}
@@ -0,0 +1,6 @@
import { MultiChoiceTConditionOperator } from '../enums/multi-choice-tcondition-operator.enum';
export interface MultiChoiceCondition {
choiceIds: number[];
operator: MultiChoiceTConditionOperator;
}
@@ -1,3 +0,0 @@
export interface MultiChoiceMetadata {
allowOther?: boolean;
}
@@ -0,0 +1,3 @@
export interface SingleChoiceMetadata {
allowOther?: boolean;
}
@@ -15,6 +15,8 @@ import {
import { UpdateQuestionTransitionDTO } from '../../dtos/update-question-transition.dto';
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
import { AppException } from '@/common/exceptions/app.exception';
import { QuestionTransitionType } from '../../enums/question-transition-type.enum';
import { MultiChoiceCondition } from '../../interfaces/multi-choice-condition.interface';
@Injectable()
export class QuestionTransitionCommandProvider {
@@ -44,7 +46,15 @@ export class QuestionTransitionCommandProvider {
fromQuestion,
toQuestion,
type,
condition: conditionValue,
condition:
type !== QuestionTransitionType.MULTI_CHOICE
? conditionValue
: {
...conditionValue,
choiceIds: [
...(conditionValue as MultiChoiceCondition).choiceIds,
].sort((a, b) => a - b),
},
});
try {
@@ -1,6 +1,7 @@
import { AppException } from '@/common/exceptions/app.exception';
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
import { MultiChoiceCondition } from '@/modules/questions/interfaces/multi-choice-condition.interface';
import { NumericCondition } from '@/modules/questions/interfaces/numeric-condition.interface';
import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-choice-condition.interface';
import { TransitionValidateOptions } from '@/modules/questions/interfaces/transition-validate-options.interface';
@@ -147,6 +148,20 @@ export class CheckTransitionDBProvider {
});
}
if (options.type === QuestionTransitionType.MULTI_CHOICE) {
const condition = options.conditionValue as MultiChoiceCondition;
qb.andWhere(`t.condition ->> 'operator' = :operator`, {
operator: condition.operator,
});
qb.andWhere(`t.condition -> 'choiceIds' = :choiceIds::jsonb`, {
choiceIds: JSON.stringify(
[...condition.choiceIds].sort((a, b) => a - b),
),
});
}
if (options.type === QuestionTransitionType.NUMBER_RANGE) {
qb.andWhere(`t.condition ->> 'min' = :min`, {
min: String((options.conditionValue as NumericCondition).min),
@@ -1,4 +1,4 @@
import { MultiChoiceMetadata } from '../interfaces/multi-choice-metadata.interface';
import { SingleChoiceMetadata } from '../interfaces/single-choice-metadata.interface';
import { NumericMetadata } from '../interfaces/numeric-metadata.interface';
export type QuestionMetadata = NumericMetadata | MultiChoiceMetadata;
export type QuestionMetadata = NumericMetadata | SingleChoiceMetadata;
@@ -1,7 +1,6 @@
import { MultiChoiceCondition } from '../interfaces/multi-choice-condition.interface';
import { NumericCondition } from '../interfaces/numeric-condition.interface';
import { SingleChoiceCondition } from '../interfaces/single-choice-condition.interface';
export type TransitionConditionType =
| SingleChoiceCondition
| NumericCondition
| null;
SingleChoiceCondition | MultiChoiceCondition | NumericCondition | null;
@@ -6,6 +6,8 @@ import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-cho
import { TransitionValidateOptions } from '@/modules/questions/interfaces/transition-validate-options.interface';
import { HttpStatus } from '@nestjs/common';
import { NumericMetadata } from '../interfaces/numeric-metadata.interface';
import { MultiChoiceCondition } from '../interfaces/multi-choice-condition.interface';
import { MultiChoiceTConditionOperator } from '../enums/multi-choice-tcondition-operator.enum';
export class QuestionTransitionTypeValidator {
public static validateTransitionType(options: TransitionValidateOptions) {
@@ -26,6 +28,10 @@ export class QuestionTransitionTypeValidator {
this.validateChoiceTransition(options);
return;
case QuestionTransitionType.MULTI_CHOICE:
this.validateMultiChoiceTransition(options);
return;
case QuestionTransitionType.NUMBER_RANGE:
this.validateNumberBetweenTransition(options);
return;
@@ -104,9 +110,9 @@ export class QuestionTransitionTypeValidator {
);
}
if (fromQuestion.type !== QuestionType.MULTI_CHOICE) {
if (fromQuestion.type !== QuestionType.SINGLE_CHOICE) {
throw new AppException(
'questions.errors.questionTransition.fromQuestionMustBeMultiChoice',
'questions.errors.questionTransition.fromQuestionMustBeSingleChoice',
HttpStatus.BAD_REQUEST,
);
}
@@ -132,6 +138,83 @@ export class QuestionTransitionTypeValidator {
}
}
private static validateMultiChoiceTransition(
options: TransitionValidateOptions,
) {
const { fromQuestion, toQuestion, conditionValue } = options;
if (!fromQuestion || !toQuestion) {
throw new AppException(
'questions.errors.questionTransition.invalidTransition',
HttpStatus.BAD_REQUEST,
);
}
if (fromQuestion.type !== QuestionType.MULTI_CHOICE) {
throw new AppException(
'questions.errors.questionTransition.fromQuestionMustBeMultiChoice',
HttpStatus.BAD_REQUEST,
);
}
const condition = conditionValue as MultiChoiceCondition;
if (
!condition ||
!Array.isArray(condition.choiceIds) ||
condition.choiceIds.length === 0
) {
throw new AppException(
'questions.errors.questionTransition.invalidMultiChoiceCondition',
HttpStatus.BAD_REQUEST,
);
}
if (
!Object.values(MultiChoiceTConditionOperator).includes(condition.operator)
) {
throw new AppException(
'questions.errors.questionTransition.invalidMultiChoiceOperator',
HttpStatus.BAD_REQUEST,
);
}
const choiceIdsAreValid = condition.choiceIds.every(
(choiceId) => typeof choiceId === 'number' && Number.isInteger(choiceId),
);
if (!choiceIdsAreValid) {
throw new AppException(
'questions.errors.questionTransition.invalidMultiChoiceCondition',
HttpStatus.BAD_REQUEST,
);
}
const uniqueChoiceIds = new Set(condition.choiceIds);
if (uniqueChoiceIds.size !== condition.choiceIds.length) {
throw new AppException(
'questions.errors.questionTransition.duplicateChoiceIds',
HttpStatus.BAD_REQUEST,
);
}
const questionChoiceIds = new Set(
fromQuestion.choices?.map((choice) => choice.id) ?? [],
);
const allChoicesBelongToQuestion = condition.choiceIds.every((choiceId) =>
questionChoiceIds.has(choiceId),
);
if (!allChoicesBelongToQuestion) {
throw new AppException(
'questions.errors.questionTransition.choiceNotBelongToQuestion',
HttpStatus.BAD_REQUEST,
);
}
}
private static validateNumberBetweenTransition(
options: TransitionValidateOptions,
) {
@@ -1,4 +1,4 @@
import { IsNumber, IsOptional } from 'class-validator';
import { IsArray, IsInt, IsNumber, IsOptional } from 'class-validator';
import { SubmissionAnswer } from '../decorators/submission-answer.decorator';
export class AnswerSubmissionDTO {
@@ -6,6 +6,11 @@ export class AnswerSubmissionDTO {
@IsOptional()
choiceId?: number;
@IsArray()
@IsOptional()
@IsInt({ each: true })
choiceIds?: number[];
@IsNumber()
@IsOptional()
numericValue?: number;
@@ -28,7 +28,7 @@ export const checkAnswerCorrectness = (
}
}
if (question.type === QuestionType.MULTI_CHOICE) {
if (question.type === QuestionType.SINGLE_CHOICE) {
if (choiceId === undefined || choiceId === null) {
throw new BadRequestException('submissions.errors.emptyChoiceId');
}
@@ -18,7 +18,7 @@ export const getNextTransition = (
);
if (
question.type === QuestionType.MULTI_CHOICE &&
question.type === QuestionType.SINGLE_CHOICE &&
answerSubmissionDto.choiceId !== undefined
) {
const transition = transitions.find(
@@ -131,7 +131,7 @@ export class AnswerAuthenticatedProvider {
});
if (
currentQuestion.type === QuestionType.MULTI_CHOICE &&
currentQuestion.type === QuestionType.SINGLE_CHOICE &&
answerSubmissionDto.choiceId !== undefined
) {
const choice = currentQuestion.choices.find(
@@ -126,7 +126,7 @@ export class AnswerGuestProvider {
};
if (
currentQuestion.type === QuestionType.MULTI_CHOICE &&
currentQuestion.type === QuestionType.SINGLE_CHOICE &&
answerSubmissionDto.choiceId !== undefined
) {
const choice = currentQuestion.choices.find(
@@ -162,7 +162,7 @@ export class CalculateResultProvider {
if (!question) continue;
if (
question.type === QuestionType.MULTI_CHOICE &&
question.type === QuestionType.SINGLE_CHOICE &&
answer.choiceId !== undefined
) {
const choice =
@@ -192,7 +192,7 @@ export class CalculateResultProvider {
questions.forEach((question) => {
const answerScores: number[] = [];
if (question.type === QuestionType.MULTI_CHOICE) {
if (question.type === QuestionType.SINGLE_CHOICE) {
question.choices.forEach((choice) => {
answerScores.push(choice.score);
});
@@ -0,0 +1,159 @@
import { AppException } from '@/common/exceptions/app.exception';
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
import { Question } from '@/modules/questions/entities/question.entity';
import { MultiChoiceTConditionOperator } from '@/modules/questions/enums/multi-choice-tcondition-operator.enum';
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
import { QuestionType } from '@/modules/questions/enums/question-type.enum';
import { MultiChoiceCondition } from '@/modules/questions/interfaces/multi-choice-condition.interface';
import { HttpStatus } from '@nestjs/common';
export class MultiChoiceTransitionValidator {
public static validateMultiChoiceTransitions(
questions: Question[],
transitions: QuestionTransition[],
) {
for (const question of questions) {
if (question.type !== QuestionType.MULTI_CHOICE) {
continue;
}
const outgoing = transitions.filter(
(t) => t.fromQuestion?.id === question.id,
);
const conditional = outgoing.filter(
(t) => t.type === QuestionTransitionType.MULTI_CHOICE,
);
if (conditional.length === 0) {
continue;
}
// اگر transition شرطی داریم، باید fallback داشته باشیم.
const hasDefault = outgoing.some(
(t) => t.type === QuestionTransitionType.DEFAULT,
);
const hasEnd = outgoing.some(
(t) => t.type === QuestionTransitionType.END,
);
if (!hasDefault && !hasEnd) {
throw new AppException(
'tests.errors.multiChoiceMustHaveFallback',
HttpStatus.BAD_REQUEST,
);
}
if (hasDefault && hasEnd) {
throw new AppException(
'tests.errors.questionHasMultipleFallbacks',
HttpStatus.BAD_REQUEST,
);
}
for (let i = 0; i < conditional.length; i++) {
for (let j = i + 1; j < conditional.length; j++) {
if (
this.multiChoiceConditionsOverlap(
conditional[i].condition as MultiChoiceCondition,
conditional[j].condition as MultiChoiceCondition,
)
) {
throw new AppException(
'tests.errors.multiChoiceTransitionsOverlap',
HttpStatus.BAD_REQUEST,
);
}
}
}
}
}
public static multiChoiceConditionsOverlap(
a: MultiChoiceCondition,
b: MultiChoiceCondition,
): boolean {
const aIds = new Set(a.choiceIds);
const bIds = new Set(b.choiceIds);
if (
a.operator === MultiChoiceTConditionOperator.EXACT &&
b.operator === MultiChoiceTConditionOperator.EXACT
) {
return this.sameSet(aIds, bIds);
}
if (
a.operator === MultiChoiceTConditionOperator.EXACT &&
b.operator === MultiChoiceTConditionOperator.ALL
) {
return this.isSubset(bIds, aIds);
}
if (
a.operator === MultiChoiceTConditionOperator.ALL &&
b.operator === MultiChoiceTConditionOperator.EXACT
) {
return this.isSubset(aIds, bIds);
}
if (
a.operator === MultiChoiceTConditionOperator.EXACT &&
b.operator === MultiChoiceTConditionOperator.ANY
) {
return this.hasIntersection(aIds, bIds);
}
if (
a.operator === MultiChoiceTConditionOperator.ANY &&
b.operator === MultiChoiceTConditionOperator.EXACT
) {
return this.hasIntersection(aIds, bIds);
}
if (
a.operator === MultiChoiceTConditionOperator.ANY &&
b.operator === MultiChoiceTConditionOperator.ANY
) {
return this.hasIntersection(aIds, bIds);
}
// ALL + ALL
if (
a.operator === MultiChoiceTConditionOperator.ALL &&
b.operator === MultiChoiceTConditionOperator.ALL
) {
return true;
}
// ANY + ALL
return true;
}
public static sameSet(a: Set<number>, b: Set<number>): boolean {
if (a.size !== b.size) return false;
for (const id of a) {
if (!b.has(id)) return false;
}
return true;
}
public static isSubset(subset: Set<number>, superset: Set<number>): boolean {
for (const id of subset) {
if (!superset.has(id)) return false;
}
return true;
}
public static hasIntersection(a: Set<number>, b: Set<number>): boolean {
for (const id of a) {
if (b.has(id)) return true;
}
return false;
}
}