diff --git a/src/i18n/en/questions.json b/src/i18n/en/questions.json index e3c6cf6..ca81be5 100644 --- a/src/i18n/en/questions.json +++ b/src/i18n/en/questions.json @@ -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!", diff --git a/src/i18n/fa/questions.json b/src/i18n/fa/questions.json index 4445bb7..e404539 100644 --- a/src/i18n/fa/questions.json +++ b/src/i18n/fa/questions.json @@ -31,8 +31,12 @@ "endMustNotHaveCondition": "حالت پایان نباید شرط داشته باشد.", "invalidTransition": "مسیر اشتباه است!", "defaultMustNotHaveCondition": "حالت پیشفرض نباید شرط داشته باشد.", - "fromQuestionMustBeMultiChoice": "سوال مبدا باید چند گزینه ای باشد.", + "fromQuestionMustBeSingleChoice": "سوال مبدا باید تک انتخابی باشد.", + "fromQuestionMustBeMultiChoice": "سوال مبدا باید چند انتخابی باشد.", "invalidChoiceCondition": "شرط گزینه اشتباه است!", + "invalidMultiChoiceCondition": "شرط گزینه ها اشتباه است!", + "invalidMultiChoiceOperator": "عملگر شرط اشتباه است!", + "duplicateChoiceIds": "شناسه گزینه ها تکراری است!", "choiceNotBelongToQuestion": "گزینه متعلق به سوال نیست.", "fromQuestionMustBeNumeric": "سوال مبدا باید از نوع عددی باشد.", "invalidNumberRange": "بازه عددی اشتباه است!", diff --git a/src/migrations/1786856474000-MultiChoiceTransitionUnqIndex.ts b/src/migrations/1786856474000-MultiChoiceTransitionUnqIndex.ts new file mode 100644 index 0000000..2dec00e --- /dev/null +++ b/src/migrations/1786856474000-MultiChoiceTransitionUnqIndex.ts @@ -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 { + 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 { + await queryRunner.query(` + DROP INDEX ${UNIQUE_QUESTION_MULTI_CHOICE} + `); + } +} diff --git a/src/modules/questions/constants/index.ts b/src/modules/questions/constants/index.ts index a8bd0d6..4ab9c17 100644 --- a/src/modules/questions/constants/index.ts +++ b/src/modules/questions/constants/index.ts @@ -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'; diff --git a/src/modules/questions/dtos/create-question.dto.ts b/src/modules/questions/dtos/create-question.dto.ts index 689df78..8a1f2a3 100644 --- a/src/modules/questions/dtos/create-question.dto.ts +++ b/src/modules/questions/dtos/create-question.dto.ts @@ -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; } diff --git a/src/modules/questions/dtos/multi-choice-metadata.dto.ts b/src/modules/questions/dtos/single-choice-metadata.dto.ts similarity index 74% rename from src/modules/questions/dtos/multi-choice-metadata.dto.ts rename to src/modules/questions/dtos/single-choice-metadata.dto.ts index 43bd1a1..cdce8d9 100644 --- a/src/modules/questions/dtos/multi-choice-metadata.dto.ts +++ b/src/modules/questions/dtos/single-choice-metadata.dto.ts @@ -1,6 +1,6 @@ import { IsBoolean, IsOptional } from 'class-validator'; -export class MultiChoiceMetadataDTO { +export class SingleChoiceMetadataDTO { @IsBoolean() @IsOptional() allowOther?: boolean; diff --git a/src/modules/questions/dtos/update-question.dto.ts b/src/modules/questions/dtos/update-question.dto.ts index 8f45b23..c10246a 100644 --- a/src/modules/questions/dtos/update-question.dto.ts +++ b/src/modules/questions/dtos/update-question.dto.ts @@ -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; } diff --git a/src/modules/questions/entities/question.entity.ts b/src/modules/questions/entities/question.entity.ts index 05ee660..091ac15 100644 --- a/src/modules/questions/entities/question.entity.ts +++ b/src/modules/questions/entities/question.entity.ts @@ -29,7 +29,7 @@ export class Question { @Column({ type: 'enum', enum: QuestionType, - default: QuestionType.MULTI_CHOICE, + default: QuestionType.SINGLE_CHOICE, }) type!: QuestionType; diff --git a/src/modules/questions/enums/multi-choice-tcondition-operator.enum.ts b/src/modules/questions/enums/multi-choice-tcondition-operator.enum.ts new file mode 100644 index 0000000..a84220b --- /dev/null +++ b/src/modules/questions/enums/multi-choice-tcondition-operator.enum.ts @@ -0,0 +1,5 @@ +export enum MultiChoiceTConditionOperator { + ANY = 'any', + ALL = 'all', + EXACT = 'exact', +} diff --git a/src/modules/questions/enums/question-transition-type.enum.ts b/src/modules/questions/enums/question-transition-type.enum.ts index 9cbb82f..3aa0d9b 100644 --- a/src/modules/questions/enums/question-transition-type.enum.ts +++ b/src/modules/questions/enums/question-transition-type.enum.ts @@ -2,6 +2,7 @@ export enum QuestionTransitionType { START = 'start', DEFAULT = 'default', CHOICE = 'choice', + MULTI_CHOICE = 'multi-choice', NUMBER_RANGE = 'number-range', END = 'end', } diff --git a/src/modules/questions/enums/question-type.enum.ts b/src/modules/questions/enums/question-type.enum.ts index 9a157e3..0e2444b 100644 --- a/src/modules/questions/enums/question-type.enum.ts +++ b/src/modules/questions/enums/question-type.enum.ts @@ -1,4 +1,5 @@ export enum QuestionType { + SINGLE_CHOICE = 'single-choice', MULTI_CHOICE = 'multi-choice', NUMERIC = 'numeric', TEXT = 'text', diff --git a/src/modules/questions/helpers/validate-metadata.helper.ts b/src/modules/questions/helpers/validate-metadata.helper.ts index d374aed..ee70128 100644 --- a/src/modules/questions/helpers/validate-metadata.helper.ts +++ b/src/modules/questions/helpers/validate-metadata.helper.ts @@ -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; } diff --git a/src/modules/questions/interfaces/multi-choice-condition.interface.ts b/src/modules/questions/interfaces/multi-choice-condition.interface.ts new file mode 100644 index 0000000..d5e05d4 --- /dev/null +++ b/src/modules/questions/interfaces/multi-choice-condition.interface.ts @@ -0,0 +1,6 @@ +import { MultiChoiceTConditionOperator } from '../enums/multi-choice-tcondition-operator.enum'; + +export interface MultiChoiceCondition { + choiceIds: number[]; + operator: MultiChoiceTConditionOperator; +} diff --git a/src/modules/questions/interfaces/multi-choice-metadata.interface.ts b/src/modules/questions/interfaces/multi-choice-metadata.interface.ts deleted file mode 100644 index e49191c..0000000 --- a/src/modules/questions/interfaces/multi-choice-metadata.interface.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface MultiChoiceMetadata { - allowOther?: boolean; -} diff --git a/src/modules/questions/interfaces/single-choice-metadata.interface.ts b/src/modules/questions/interfaces/single-choice-metadata.interface.ts new file mode 100644 index 0000000..8992b90 --- /dev/null +++ b/src/modules/questions/interfaces/single-choice-metadata.interface.ts @@ -0,0 +1,3 @@ +export interface SingleChoiceMetadata { + allowOther?: boolean; +} diff --git a/src/modules/questions/providers/transition/question-transition-command.provider.ts b/src/modules/questions/providers/transition/question-transition-command.provider.ts index 15428c8..3c188ef 100644 --- a/src/modules/questions/providers/transition/question-transition-command.provider.ts +++ b/src/modules/questions/providers/transition/question-transition-command.provider.ts @@ -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 { diff --git a/src/modules/questions/providers/transition/validations/check-transition-db.provider.ts b/src/modules/questions/providers/transition/validations/check-transition-db.provider.ts index 0517013..d2713a7 100644 --- a/src/modules/questions/providers/transition/validations/check-transition-db.provider.ts +++ b/src/modules/questions/providers/transition/validations/check-transition-db.provider.ts @@ -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), diff --git a/src/modules/questions/types/question-metadata.type.ts b/src/modules/questions/types/question-metadata.type.ts index 9a98e20..5c32ea3 100644 --- a/src/modules/questions/types/question-metadata.type.ts +++ b/src/modules/questions/types/question-metadata.type.ts @@ -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; diff --git a/src/modules/questions/types/transition-condition.type.ts b/src/modules/questions/types/transition-condition.type.ts index fd062e3..2bea8e6 100644 --- a/src/modules/questions/types/transition-condition.type.ts +++ b/src/modules/questions/types/transition-condition.type.ts @@ -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; diff --git a/src/modules/questions/validators/question-transition-type.validator.ts b/src/modules/questions/validators/question-transition-type.validator.ts index 3b7a5c1..54d763e 100644 --- a/src/modules/questions/validators/question-transition-type.validator.ts +++ b/src/modules/questions/validators/question-transition-type.validator.ts @@ -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, ) { diff --git a/src/modules/submissions/dtos/answer-submission.dto.ts b/src/modules/submissions/dtos/answer-submission.dto.ts index 79d7e93..7eb8661 100644 --- a/src/modules/submissions/dtos/answer-submission.dto.ts +++ b/src/modules/submissions/dtos/answer-submission.dto.ts @@ -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; diff --git a/src/modules/submissions/helpers/check-answer-correctness.helper.ts b/src/modules/submissions/helpers/check-answer-correctness.helper.ts index 7b734d7..1750161 100644 --- a/src/modules/submissions/helpers/check-answer-correctness.helper.ts +++ b/src/modules/submissions/helpers/check-answer-correctness.helper.ts @@ -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'); } diff --git a/src/modules/submissions/helpers/get-next-transition.helper.ts b/src/modules/submissions/helpers/get-next-transition.helper.ts index 60f7cd3..e328352 100644 --- a/src/modules/submissions/helpers/get-next-transition.helper.ts +++ b/src/modules/submissions/helpers/get-next-transition.helper.ts @@ -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( diff --git a/src/modules/submissions/providers/answer/answer-authenticated.provider.ts b/src/modules/submissions/providers/answer/answer-authenticated.provider.ts index c99e933..60594bf 100644 --- a/src/modules/submissions/providers/answer/answer-authenticated.provider.ts +++ b/src/modules/submissions/providers/answer/answer-authenticated.provider.ts @@ -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( diff --git a/src/modules/submissions/providers/answer/answer-guest.provider.ts b/src/modules/submissions/providers/answer/answer-guest.provider.ts index 0f9e3a8..0e256fa 100644 --- a/src/modules/submissions/providers/answer/answer-guest.provider.ts +++ b/src/modules/submissions/providers/answer/answer-guest.provider.ts @@ -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( diff --git a/src/modules/submissions/providers/answer/calculate-result.provider.ts b/src/modules/submissions/providers/answer/calculate-result.provider.ts index eb65318..d37c931 100644 --- a/src/modules/submissions/providers/answer/calculate-result.provider.ts +++ b/src/modules/submissions/providers/answer/calculate-result.provider.ts @@ -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); }); diff --git a/src/modules/tests/validators/multi-choice-transition.validator.ts b/src/modules/tests/validators/multi-choice-transition.validator.ts new file mode 100644 index 0000000..a062976 --- /dev/null +++ b/src/modules/tests/validators/multi-choice-transition.validator.ts @@ -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, b: Set): 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, superset: Set): boolean { + for (const id of subset) { + if (!superset.has(id)) return false; + } + + return true; + } + + public static hasIntersection(a: Set, b: Set): boolean { + for (const id of a) { + if (b.has(id)) return true; + } + + return false; + } +}