feat: seperate single and multi choice questions
This commit is contained in:
@@ -31,8 +31,12 @@
|
|||||||
"endMustNotHaveCondition": "End must not have condition.",
|
"endMustNotHaveCondition": "End must not have condition.",
|
||||||
"invalidTransition": "Invalid Transition!",
|
"invalidTransition": "Invalid Transition!",
|
||||||
"defaultMustNotHaveCondition": "Default must not have condition.",
|
"defaultMustNotHaveCondition": "Default must not have condition.",
|
||||||
|
"fromQuestionMustBeSingleChoice": "fromQuestion must be single choice.",
|
||||||
"fromQuestionMustBeMultiChoice": "fromQuestion must be multi choice.",
|
"fromQuestionMustBeMultiChoice": "fromQuestion must be multi choice.",
|
||||||
"invalidChoiceCondition": "Invalid choice condition!",
|
"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.",
|
"choiceNotBelongToQuestion": "Choice does not belong to the question.",
|
||||||
"fromQuestionMustBeNumeric": "fromQuestion must be numeric.",
|
"fromQuestionMustBeNumeric": "fromQuestion must be numeric.",
|
||||||
"invalidNumberRange": "Invalid number range!",
|
"invalidNumberRange": "Invalid number range!",
|
||||||
|
|||||||
@@ -31,8 +31,12 @@
|
|||||||
"endMustNotHaveCondition": "حالت پایان نباید شرط داشته باشد.",
|
"endMustNotHaveCondition": "حالت پایان نباید شرط داشته باشد.",
|
||||||
"invalidTransition": "مسیر اشتباه است!",
|
"invalidTransition": "مسیر اشتباه است!",
|
||||||
"defaultMustNotHaveCondition": "حالت پیشفرض نباید شرط داشته باشد.",
|
"defaultMustNotHaveCondition": "حالت پیشفرض نباید شرط داشته باشد.",
|
||||||
"fromQuestionMustBeMultiChoice": "سوال مبدا باید چند گزینه ای باشد.",
|
"fromQuestionMustBeSingleChoice": "سوال مبدا باید تک انتخابی باشد.",
|
||||||
|
"fromQuestionMustBeMultiChoice": "سوال مبدا باید چند انتخابی باشد.",
|
||||||
"invalidChoiceCondition": "شرط گزینه اشتباه است!",
|
"invalidChoiceCondition": "شرط گزینه اشتباه است!",
|
||||||
|
"invalidMultiChoiceCondition": "شرط گزینه ها اشتباه است!",
|
||||||
|
"invalidMultiChoiceOperator": "عملگر شرط اشتباه است!",
|
||||||
|
"duplicateChoiceIds": "شناسه گزینه ها تکراری است!",
|
||||||
"choiceNotBelongToQuestion": "گزینه متعلق به سوال نیست.",
|
"choiceNotBelongToQuestion": "گزینه متعلق به سوال نیست.",
|
||||||
"fromQuestionMustBeNumeric": "سوال مبدا باید از نوع عددی باشد.",
|
"fromQuestionMustBeNumeric": "سوال مبدا باید از نوع عددی باشد.",
|
||||||
"invalidNumberRange": "بازه عددی اشتباه است!",
|
"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}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,3 +8,4 @@ export const UNIQUE_QUESTION_END = 'UNQ_QUESTION_END';
|
|||||||
export const UNIQUE_TEST_START = 'UNQ_TEST_START';
|
export const UNIQUE_TEST_START = 'UNQ_TEST_START';
|
||||||
export const EX_QUESTION_NUMBER_RANGE_OVERLAP =
|
export const EX_QUESTION_NUMBER_RANGE_OVERLAP =
|
||||||
'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,
|
Validate,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { QuestionType } from '../enums/question-type.enum';
|
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 { NumericMetadataDTO } from './numeric-metadata.dto';
|
||||||
import { QuestionMetadataValidator } from '../validators/question-metadata.validator';
|
import { QuestionMetadataValidator } from '../validators/question-metadata.validator';
|
||||||
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
||||||
@@ -60,5 +60,5 @@ export class CreateQuestionDTO {
|
|||||||
|
|
||||||
@Validate(QuestionMetadataValidator)
|
@Validate(QuestionMetadataValidator)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
metadata?: MultiChoiceMetadataDTO | NumericMetadataDTO;
|
metadata?: SingleChoiceMetadataDTO | NumericMetadataDTO;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
import { IsBoolean, IsOptional } from 'class-validator';
|
import { IsBoolean, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export class MultiChoiceMetadataDTO {
|
export class SingleChoiceMetadataDTO {
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
allowOther?: boolean;
|
allowOther?: boolean;
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
import { i18nValidationMessage as t } from 'nestjs-i18n';
|
||||||
import { QuestionType } from '../enums/question-type.enum';
|
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 { NumericMetadataDTO } from './numeric-metadata.dto';
|
||||||
|
|
||||||
export class UpdateQuestionDTO {
|
export class UpdateQuestionDTO {
|
||||||
@@ -48,5 +48,5 @@ export class UpdateQuestionDTO {
|
|||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
metadata?: MultiChoiceMetadataDTO | NumericMetadataDTO;
|
metadata?: SingleChoiceMetadataDTO | NumericMetadataDTO;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class Question {
|
|||||||
@Column({
|
@Column({
|
||||||
type: 'enum',
|
type: 'enum',
|
||||||
enum: QuestionType,
|
enum: QuestionType,
|
||||||
default: QuestionType.MULTI_CHOICE,
|
default: QuestionType.SINGLE_CHOICE,
|
||||||
})
|
})
|
||||||
type!: QuestionType;
|
type!: QuestionType;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export enum MultiChoiceTConditionOperator {
|
||||||
|
ANY = 'any',
|
||||||
|
ALL = 'all',
|
||||||
|
EXACT = 'exact',
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export enum QuestionTransitionType {
|
|||||||
START = 'start',
|
START = 'start',
|
||||||
DEFAULT = 'default',
|
DEFAULT = 'default',
|
||||||
CHOICE = 'choice',
|
CHOICE = 'choice',
|
||||||
|
MULTI_CHOICE = 'multi-choice',
|
||||||
NUMBER_RANGE = 'number-range',
|
NUMBER_RANGE = 'number-range',
|
||||||
END = 'end',
|
END = 'end',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export enum QuestionType {
|
export enum QuestionType {
|
||||||
|
SINGLE_CHOICE = 'single-choice',
|
||||||
MULTI_CHOICE = 'multi-choice',
|
MULTI_CHOICE = 'multi-choice',
|
||||||
NUMERIC = 'numeric',
|
NUMERIC = 'numeric',
|
||||||
TEXT = 'text',
|
TEXT = 'text',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { plainToInstance } from 'class-transformer';
|
|||||||
import { QuestionType } from '../enums/question-type.enum';
|
import { QuestionType } from '../enums/question-type.enum';
|
||||||
import { QuestionMetadata } from '../types/question-metadata.type';
|
import { QuestionMetadata } from '../types/question-metadata.type';
|
||||||
import { NumericMetadataDTO } from '../dtos/numeric-metadata.dto';
|
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 { validate } from 'class-validator';
|
||||||
import { ValidationError } from '@nestjs/common';
|
import { ValidationError } from '@nestjs/common';
|
||||||
|
|
||||||
@@ -22,8 +22,8 @@ export const validateMetadata = async (
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
case QuestionType.MULTI_CHOICE:
|
case QuestionType.SINGLE_CHOICE:
|
||||||
validateDto = plainToInstance(MultiChoiceMetadataDTO, metadata);
|
validateDto = plainToInstance(SingleChoiceMetadataDTO, metadata);
|
||||||
break;
|
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 { UpdateQuestionTransitionDTO } from '../../dtos/update-question-transition.dto';
|
||||||
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception';
|
||||||
import { AppException } from '@/common/exceptions/app.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()
|
@Injectable()
|
||||||
export class QuestionTransitionCommandProvider {
|
export class QuestionTransitionCommandProvider {
|
||||||
@@ -44,7 +46,15 @@ export class QuestionTransitionCommandProvider {
|
|||||||
fromQuestion,
|
fromQuestion,
|
||||||
toQuestion,
|
toQuestion,
|
||||||
type,
|
type,
|
||||||
condition: conditionValue,
|
condition:
|
||||||
|
type !== QuestionTransitionType.MULTI_CHOICE
|
||||||
|
? conditionValue
|
||||||
|
: {
|
||||||
|
...conditionValue,
|
||||||
|
choiceIds: [
|
||||||
|
...(conditionValue as MultiChoiceCondition).choiceIds,
|
||||||
|
].sort((a, b) => a - b),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
+15
@@ -1,6 +1,7 @@
|
|||||||
import { AppException } from '@/common/exceptions/app.exception';
|
import { AppException } from '@/common/exceptions/app.exception';
|
||||||
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
import { QuestionTransition } from '@/modules/questions/entities/question-transition.entity';
|
||||||
import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum';
|
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 { NumericCondition } from '@/modules/questions/interfaces/numeric-condition.interface';
|
||||||
import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-choice-condition.interface';
|
import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-choice-condition.interface';
|
||||||
import { TransitionValidateOptions } from '@/modules/questions/interfaces/transition-validate-options.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) {
|
if (options.type === QuestionTransitionType.NUMBER_RANGE) {
|
||||||
qb.andWhere(`t.condition ->> 'min' = :min`, {
|
qb.andWhere(`t.condition ->> 'min' = :min`, {
|
||||||
min: String((options.conditionValue as NumericCondition).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';
|
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 { NumericCondition } from '../interfaces/numeric-condition.interface';
|
||||||
import { SingleChoiceCondition } from '../interfaces/single-choice-condition.interface';
|
import { SingleChoiceCondition } from '../interfaces/single-choice-condition.interface';
|
||||||
|
|
||||||
export type TransitionConditionType =
|
export type TransitionConditionType =
|
||||||
| SingleChoiceCondition
|
SingleChoiceCondition | MultiChoiceCondition | NumericCondition | null;
|
||||||
| 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 { TransitionValidateOptions } from '@/modules/questions/interfaces/transition-validate-options.interface';
|
||||||
import { HttpStatus } from '@nestjs/common';
|
import { HttpStatus } from '@nestjs/common';
|
||||||
import { NumericMetadata } from '../interfaces/numeric-metadata.interface';
|
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 {
|
export class QuestionTransitionTypeValidator {
|
||||||
public static validateTransitionType(options: TransitionValidateOptions) {
|
public static validateTransitionType(options: TransitionValidateOptions) {
|
||||||
@@ -26,6 +28,10 @@ export class QuestionTransitionTypeValidator {
|
|||||||
this.validateChoiceTransition(options);
|
this.validateChoiceTransition(options);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
case QuestionTransitionType.MULTI_CHOICE:
|
||||||
|
this.validateMultiChoiceTransition(options);
|
||||||
|
return;
|
||||||
|
|
||||||
case QuestionTransitionType.NUMBER_RANGE:
|
case QuestionTransitionType.NUMBER_RANGE:
|
||||||
this.validateNumberBetweenTransition(options);
|
this.validateNumberBetweenTransition(options);
|
||||||
return;
|
return;
|
||||||
@@ -104,9 +110,9 @@ export class QuestionTransitionTypeValidator {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fromQuestion.type !== QuestionType.MULTI_CHOICE) {
|
if (fromQuestion.type !== QuestionType.SINGLE_CHOICE) {
|
||||||
throw new AppException(
|
throw new AppException(
|
||||||
'questions.errors.questionTransition.fromQuestionMustBeMultiChoice',
|
'questions.errors.questionTransition.fromQuestionMustBeSingleChoice',
|
||||||
HttpStatus.BAD_REQUEST,
|
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(
|
private static validateNumberBetweenTransition(
|
||||||
options: TransitionValidateOptions,
|
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';
|
import { SubmissionAnswer } from '../decorators/submission-answer.decorator';
|
||||||
|
|
||||||
export class AnswerSubmissionDTO {
|
export class AnswerSubmissionDTO {
|
||||||
@@ -6,6 +6,11 @@ export class AnswerSubmissionDTO {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
choiceId?: number;
|
choiceId?: number;
|
||||||
|
|
||||||
|
@IsArray()
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt({ each: true })
|
||||||
|
choiceIds?: number[];
|
||||||
|
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
numericValue?: number;
|
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) {
|
if (choiceId === undefined || choiceId === null) {
|
||||||
throw new BadRequestException('submissions.errors.emptyChoiceId');
|
throw new BadRequestException('submissions.errors.emptyChoiceId');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const getNextTransition = (
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
question.type === QuestionType.MULTI_CHOICE &&
|
question.type === QuestionType.SINGLE_CHOICE &&
|
||||||
answerSubmissionDto.choiceId !== undefined
|
answerSubmissionDto.choiceId !== undefined
|
||||||
) {
|
) {
|
||||||
const transition = transitions.find(
|
const transition = transitions.find(
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export class AnswerAuthenticatedProvider {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
currentQuestion.type === QuestionType.MULTI_CHOICE &&
|
currentQuestion.type === QuestionType.SINGLE_CHOICE &&
|
||||||
answerSubmissionDto.choiceId !== undefined
|
answerSubmissionDto.choiceId !== undefined
|
||||||
) {
|
) {
|
||||||
const choice = currentQuestion.choices.find(
|
const choice = currentQuestion.choices.find(
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ export class AnswerGuestProvider {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (
|
if (
|
||||||
currentQuestion.type === QuestionType.MULTI_CHOICE &&
|
currentQuestion.type === QuestionType.SINGLE_CHOICE &&
|
||||||
answerSubmissionDto.choiceId !== undefined
|
answerSubmissionDto.choiceId !== undefined
|
||||||
) {
|
) {
|
||||||
const choice = currentQuestion.choices.find(
|
const choice = currentQuestion.choices.find(
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export class CalculateResultProvider {
|
|||||||
if (!question) continue;
|
if (!question) continue;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
question.type === QuestionType.MULTI_CHOICE &&
|
question.type === QuestionType.SINGLE_CHOICE &&
|
||||||
answer.choiceId !== undefined
|
answer.choiceId !== undefined
|
||||||
) {
|
) {
|
||||||
const choice =
|
const choice =
|
||||||
@@ -192,7 +192,7 @@ export class CalculateResultProvider {
|
|||||||
questions.forEach((question) => {
|
questions.forEach((question) => {
|
||||||
const answerScores: number[] = [];
|
const answerScores: number[] = [];
|
||||||
|
|
||||||
if (question.type === QuestionType.MULTI_CHOICE) {
|
if (question.type === QuestionType.SINGLE_CHOICE) {
|
||||||
question.choices.forEach((choice) => {
|
question.choices.forEach((choice) => {
|
||||||
answerScores.push(choice.score);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user