diff --git a/src/i18n/en/questions.json b/src/i18n/en/questions.json index 44922f5..bb5f575 100644 --- a/src/i18n/en/questions.json +++ b/src/i18n/en/questions.json @@ -22,7 +22,24 @@ "questionTransition": { "sameQuestion": "fromQuestion and toQuestion are the same", "fromQuestionNotBelongToTest": "fromQuestion doesnt belong to the test.", - "toQuestionNotBelongToTest": "toQuestion doesnt belong to the test." + "toQuestionNotBelongToTest": "toQuestion doesnt belong to the test.", + "startMustNotHaveFromQuestion": "Start must not have fromQuestion.", + "startMustHaveToQuestion": "Start must have toQuestion.", + "startMustNotHaveCondition": "Start must not have condition.", + "endMustHaveFromQuestion": "End must have fromQuestion.", + "endMustNotHaveToQuestion": "End must not have toQuestion.", + "endMustNotHaveCondition": "End must not have condition.", + "invalidTransition": "Invalid Transition!", + "defaultMustNotHaveCondition": "Default must not have condition.", + "fromQuestionMustBeMultiChoice": "fromQuestion must be multi choice.", + "invalidChoiceCondition": "Invalid choice condition!", + "choiceNotBelongToQuestion": "Choice does not belong to the question.", + "fromQuestionMustBeNumeric": "fromQuestion must be numeric.", + "invalidNumberRange": "Invalid number range!", + "startAlreadyExists": "Start already exists.", + "endAlreadyExists": "End already exists.", + "defaultAlreadyExists": "Default already exists.", + "duplicateTransition": "Duplicate Transition!" } }, "messages": { diff --git a/src/i18n/fa/questions.json b/src/i18n/fa/questions.json index 4e7435e..db8ebe1 100644 --- a/src/i18n/fa/questions.json +++ b/src/i18n/fa/questions.json @@ -22,7 +22,24 @@ "questionTransition": { "sameQuestion": "سوال مبدا و مقصد یکی است.", "fromQuestionNotBelongToTest": "سوال مبدا متعلق به تست نیست.", - "toQuestionNotBelongToTest": "سوال مقصد متعلق به تست نیست." + "toQuestionNotBelongToTest": "سوال مقصد متعلق به تست نیست.", + "startMustNotHaveFromQuestion": "حالت شروع نباید سوال مبدا داشته باشد.", + "startMustHaveToQuestion": "حالت شروع باید سوال مقصد داشته باشد.", + "startMustNotHaveCondition": "حالت شروع نباید شرط داشته باشد.", + "endMustHaveFromQuestion": "حالت پایان باید سوال مقصد داشته باشد.", + "endMustNotHaveToQuestion": "حالت پایان نباید سوال مبدا داشته باشد.", + "endMustNotHaveCondition": "حالت پایان نباید شرط داشته باشد.", + "invalidTransition": "مسیر اشتباه است!", + "defaultMustNotHaveCondition": "حالت پیشفرض نباید شرط داشته باشد.", + "fromQuestionMustBeMultiChoice": "سوال مبدا باید چند گزینه ای باشد.", + "invalidChoiceCondition": "شرط گزینه اشتباه است!", + "choiceNotBelongToQuestion": "گزینه متعلق به سوال نیست.", + "fromQuestionMustBeNumeric": "سوال مبدا باید از نوع عددی باشد.", + "invalidNumberRange": "بازه عددی اشتباه است!", + "startAlreadyExists": "حالت شروع وجود دارد.", + "endAlreadyExists": "حالت پایان وجود دارد.", + "defaultAlreadyExists": "حالت پیشفرض وجود دارد.", + "duplicateTransition": "مسیر تکراری است!" } }, "messages": { 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 new file mode 100644 index 0000000..3e161a2 --- /dev/null +++ b/src/modules/questions/providers/transition/validations/check-transition-db.provider.ts @@ -0,0 +1,171 @@ +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 { 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'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import type { Repository } from 'typeorm'; + +@Injectable() +export class CheckTransitionDBProvider { + constructor( + /** + * Inject Transitions Repository + */ + @InjectRepository(QuestionTransition) + private readonly transitionsRepository: Repository, + ) {} + + public async validate( + options: TransitionValidateOptions, + transitionId?: number, + ) { + await this.validateSingleStart(options, transitionId); + + await this.validateSingleEnd(options, transitionId); + + await this.validateDefaultTransition(options, transitionId); + + await this.validateDuplicateCondition(options, transitionId); + } + + private async validateSingleStart( + options: TransitionValidateOptions, + transitionId?: number, + ) { + if (options.type !== QuestionTransitionType.START) return; + + const qb = this.transitionsRepository + .createQueryBuilder('t') + .where('t.testId = :testId', { + testId: options.test.id, + }) + .andWhere('t.type = :type', { + type: QuestionTransitionType.START, + }); + + if (transitionId) { + qb.andWhere('t.id != :transitionId', { + transitionId, + }); + } + + if (await qb.getExists()) { + throw new AppException( + 'questions.errors.questionTransition.startAlreadyExists', + HttpStatus.CONFLICT, + ); + } + } + + private async validateSingleEnd( + options: TransitionValidateOptions, + transitionId?: number, + ) { + if (options.type !== QuestionTransitionType.END) return; + + const qb = this.transitionsRepository + .createQueryBuilder('t') + .where('t.fromQuestionId = :fromQuestionId', { + fromQuestionId: options.fromQuestion?.id, + }) + .andWhere('t.type = :type', { + type: QuestionTransitionType.END, + }); + + if (transitionId) { + qb.andWhere('t.id != :transitionId', { + transitionId, + }); + } + + if (await qb.getExists()) { + throw new AppException( + 'questions.errors.questionTransition.endAlreadyExists', + HttpStatus.CONFLICT, + ); + } + } + + private async validateDefaultTransition( + options: TransitionValidateOptions, + transitionId?: number, + ) { + if (options.type !== QuestionTransitionType.DEFAULT) return; + + const qb = this.transitionsRepository + .createQueryBuilder('t') + .where('t.fromQuestionId = :fromQuestionId', { + fromQuestionId: options.fromQuestion?.id, + }) + .andWhere('t.type = :type', { + type: QuestionTransitionType.DEFAULT, + }); + + if (transitionId) { + qb.andWhere('t.id != :transitionId', { + transitionId, + }); + } + + if (await qb.getExists()) { + throw new AppException( + 'questions.errors.questionTransition.defaultAlreadyExists', + HttpStatus.CONFLICT, + ); + } + } + + private async validateDuplicateCondition( + options: TransitionValidateOptions, + transitionId?: number, + ) { + if ( + options.type === QuestionTransitionType.DEFAULT || + options.type === QuestionTransitionType.START || + options.type === QuestionTransitionType.END + ) { + return; + } + + const qb = this.transitionsRepository + .createQueryBuilder('t') + .where('t.fromQuestionId = :fromQuestionId', { + fromQuestionId: options.fromQuestion?.id, + }) + .andWhere('t.type = :type', { + type: options.type, + }); + + if (options.type === QuestionTransitionType.CHOICE) { + qb.andWhere(`t.conditionValue ->> 'choiceId' = :choiceId`, { + choiceId: String( + (options.conditionValue as SingleChoiceCondition).choiceId, + ), + }); + } + + if (options.type === QuestionTransitionType.NUMBER_RANGE) { + qb.andWhere(`t.conditionValue ->> 'min' = :min`, { + min: String((options.conditionValue as NumericCondition).min), + }).andWhere(`t.conditionValue ->> 'max' = :max`, { + max: String((options.conditionValue as NumericCondition).max), + }); + } + + if (transitionId) { + qb.andWhere('t.id != :transitionId', { + transitionId, + }); + } + + if (await qb.getExists()) { + throw new AppException( + 'questions.errors.questionTransition.duplicateTransition', + HttpStatus.CONFLICT, + ); + } + } +} diff --git a/src/modules/questions/providers/transition/validations/question-transition-validator.provider.ts b/src/modules/questions/providers/transition/validations/question-transition-validator.provider.ts index a01f768..a14b876 100644 --- a/src/modules/questions/providers/transition/validations/question-transition-validator.provider.ts +++ b/src/modules/questions/providers/transition/validations/question-transition-validator.provider.ts @@ -1,6 +1,6 @@ import { HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import type { Repository } from 'typeorm'; import { TestsService } from '@/modules/tests/providers/tests.service'; import { QuestionTransition } from '../../../entities/question-transition.entity'; import { Question } from '@/modules/questions/entities/question.entity'; @@ -13,16 +13,31 @@ import { AppException } from '@/common/exceptions/app.exception'; import { QuestionsQueryProvider } from '../../questions-query.provider'; import { TransitionValidateOptions } from '../../../interfaces/transition-validate-options.interface'; import { QuestionTransitionTypeValidator } from '../../../validators/question-transition-type.validator'; +import { CheckTransitionDBProvider } from './check-transition-db.provider'; @Injectable() export class QuestionTransitionValidatorProvider { constructor( + /** + * Inject Transitions Repository + */ @InjectRepository(QuestionTransition) private readonly transitionsRepository: Repository, + /** + * Inject Tests Service + */ private readonly testsService: TestsService, + /** + * Inject Questions Query Provider + */ private readonly questionsQueryProvider: QuestionsQueryProvider, + + /** + * Inject CheckTransitionDB Provider + */ + private readonly checkTransitionDBProvider: CheckTransitionDBProvider, ) {} public async validate( @@ -120,6 +135,10 @@ export class QuestionTransitionValidatorProvider { }; QuestionTransitionTypeValidator.validateTransitionType(options); + + await this.checkTransitionDBProvider.validate(options, transitionId); + + return options; } private validateQuestions( diff --git a/src/modules/questions/questions.module.ts b/src/modules/questions/questions.module.ts index a0a0c4c..5feaa99 100644 --- a/src/modules/questions/questions.module.ts +++ b/src/modules/questions/questions.module.ts @@ -15,6 +15,8 @@ import { QuestionRangeRuleService } from './providers/range-rule/question-range- import { QuestionRangeRuleCommandProvider } from './providers/range-rule/question-range-rule-command.provider'; import { QuestionRangeRuleQueryProvider } from './providers/range-rule/question-range-rule-query.provider'; import { QuestionTransition } from './entities/question-transition.entity'; +import { CheckTransitionDBProvider } from './providers/transition/validations/check-transition-db.provider'; +import { QuestionTransitionValidatorProvider } from './providers/transition/validations/question-transition-validator.provider'; @Module({ imports: [ @@ -28,15 +30,24 @@ import { QuestionTransition } from './entities/question-transition.entity'; ], controllers: [QuestionsController], providers: [ + // Questsions QuestionsCommandProvider, QuestionsQueryProvider, QuestionsService, + + // Choices ChoicesCommandProvider, ChoicesQueryProvider, ChoicesService, + + // Question Range Rules QuestionRangeRuleCommandProvider, QuestionRangeRuleQueryProvider, QuestionRangeRuleService, + + // Question Transitions + CheckTransitionDBProvider, + QuestionTransitionValidatorProvider, ], exports: [QuestionsService, ChoicesService, QuestionRangeRuleService], }) diff --git a/src/modules/questions/validators/question-transition-type.validator.ts b/src/modules/questions/validators/question-transition-type.validator.ts index eb75d2c..3b7a5c1 100644 --- a/src/modules/questions/validators/question-transition-type.validator.ts +++ b/src/modules/questions/validators/question-transition-type.validator.ts @@ -5,6 +5,7 @@ import { NumericCondition } from '@/modules/questions/interfaces/numeric-conditi import { SingleChoiceCondition } from '@/modules/questions/interfaces/single-choice-condition.interface'; import { TransitionValidateOptions } from '@/modules/questions/interfaces/transition-validate-options.interface'; import { HttpStatus } from '@nestjs/common'; +import { NumericMetadata } from '../interfaces/numeric-metadata.interface'; export class QuestionTransitionTypeValidator { public static validateTransitionType(options: TransitionValidateOptions) { @@ -34,21 +35,21 @@ export class QuestionTransitionTypeValidator { private static validateStart(options: TransitionValidateOptions) { if (options.fromQuestion) { throw new AppException( - 'questionTransitions.errors.startMustNotHaveFromQuestion', + 'questions.errors.questionTransition.startMustNotHaveFromQuestion', HttpStatus.BAD_REQUEST, ); } if (!options.toQuestion) { throw new AppException( - 'questionTransitions.errors.startMustHaveToQuestion', + 'questions.errors.questionTransition.startMustHaveToQuestion', HttpStatus.BAD_REQUEST, ); } if (options.conditionValue) { throw new AppException( - 'questionTransitions.errors.startMustNotHaveCondition', + 'questions.errors.questionTransition.startMustNotHaveCondition', HttpStatus.BAD_REQUEST, ); } @@ -57,21 +58,21 @@ export class QuestionTransitionTypeValidator { private static validateEnd(options: TransitionValidateOptions) { if (!options.fromQuestion) { throw new AppException( - 'questionTransitions.errors.endMustHaveFromQuestion', + 'questions.errors.questionTransition.endMustHaveFromQuestion', HttpStatus.BAD_REQUEST, ); } if (options.toQuestion) { throw new AppException( - 'questionTransitions.errors.endMustNotHaveToQuestion', + 'questions.errors.questionTransition.endMustNotHaveToQuestion', HttpStatus.BAD_REQUEST, ); } if (options.conditionValue) { throw new AppException( - 'questionTransitions.errors.endMustNotHaveCondition', + 'questions.errors.questionTransition.endMustNotHaveCondition', HttpStatus.BAD_REQUEST, ); } @@ -80,14 +81,14 @@ export class QuestionTransitionTypeValidator { private static validateDefault(options: TransitionValidateOptions) { if (!options.fromQuestion || !options.toQuestion) { throw new AppException( - 'questionTransitions.errors.invalidTransition', + 'questions.errors.questionTransition.invalidTransition', HttpStatus.BAD_REQUEST, ); } if (options.conditionValue) { throw new AppException( - 'questionTransitions.errors.defaultMustNotHaveCondition', + 'questions.errors.questionTransition.defaultMustNotHaveCondition', HttpStatus.BAD_REQUEST, ); } @@ -98,14 +99,14 @@ export class QuestionTransitionTypeValidator { if (!fromQuestion || !toQuestion) { throw new AppException( - 'questionTransitions.errors.invalidTransition', + 'questions.errors.questionTransition.invalidTransition', HttpStatus.BAD_REQUEST, ); } if (fromQuestion.type !== QuestionType.MULTI_CHOICE) { throw new AppException( - 'questionTransitions.errors.fromQuestionMustBeMultiChoice', + 'questions.errors.questionTransition.fromQuestionMustBeMultiChoice', HttpStatus.BAD_REQUEST, ); } @@ -114,7 +115,7 @@ export class QuestionTransitionTypeValidator { if (typeof choiceId !== 'number') { throw new AppException( - 'questionTransitions.errors.invalidChoiceCondition', + 'questions.errors.questionTransition.invalidChoiceCondition', HttpStatus.BAD_REQUEST, ); } @@ -125,7 +126,7 @@ export class QuestionTransitionTypeValidator { if (!choiceExists) { throw new AppException( - 'questionTransitions.errors.choiceNotBelongToQuestion', + 'questions.errors.questionTransition.choiceNotBelongToQuestion', HttpStatus.BAD_REQUEST, ); } @@ -138,14 +139,14 @@ export class QuestionTransitionTypeValidator { if (!fromQuestion || !toQuestion) { throw new AppException( - 'questionTransitions.errors.invalidTransition', + 'questions.errors.questionTransition.invalidTransition', HttpStatus.BAD_REQUEST, ); } if (fromQuestion.type !== QuestionType.NUMERIC) { throw new AppException( - 'questionTransitions.errors.fromQuestionMustBeNumeric', + 'questions.errors.questionTransition.fromQuestionMustBeNumeric', HttpStatus.BAD_REQUEST, ); } @@ -158,16 +159,27 @@ export class QuestionTransitionTypeValidator { typeof condition.max !== 'number' ) { throw new AppException( - 'questionTransitions.errors.invalidNumberRange', + 'questions.errors.questionTransition.invalidNumberRange', HttpStatus.BAD_REQUEST, ); } if (condition.min > condition.max) { throw new AppException( - 'questionTransitions.errors.invalidNumberRange', + 'questions.errors.questionTransition.invalidNumberRange', HttpStatus.BAD_REQUEST, ); } + + if (fromQuestion.metadata) { + const metadata = fromQuestion.metadata as NumericMetadata; + + if (condition.min < metadata.min || condition.max > metadata.max) { + throw new AppException( + 'questions.errors.questionTransition.invalidNumberRange', + HttpStatus.BAD_REQUEST, + ); + } + } } }