Complete question transition validation

This commit is contained in:
2026-08-02 16:52:59 +03:30
parent 779565b555
commit a3b7c70999
6 changed files with 266 additions and 19 deletions
+18 -1
View File
@@ -22,7 +22,24 @@
"questionTransition": { "questionTransition": {
"sameQuestion": "fromQuestion and toQuestion are the same", "sameQuestion": "fromQuestion and toQuestion are the same",
"fromQuestionNotBelongToTest": "fromQuestion doesnt belong to the test.", "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": { "messages": {
+18 -1
View File
@@ -22,7 +22,24 @@
"questionTransition": { "questionTransition": {
"sameQuestion": "سوال مبدا و مقصد یکی است.", "sameQuestion": "سوال مبدا و مقصد یکی است.",
"fromQuestionNotBelongToTest": "سوال مبدا متعلق به تست نیست.", "fromQuestionNotBelongToTest": "سوال مبدا متعلق به تست نیست.",
"toQuestionNotBelongToTest": "سوال مقصد متعلق به تست نیست." "toQuestionNotBelongToTest": "سوال مقصد متعلق به تست نیست.",
"startMustNotHaveFromQuestion": "حالت شروع نباید سوال مبدا داشته باشد.",
"startMustHaveToQuestion": "حالت شروع باید سوال مقصد داشته باشد.",
"startMustNotHaveCondition": "حالت شروع نباید شرط داشته باشد.",
"endMustHaveFromQuestion": "حالت پایان باید سوال مقصد داشته باشد.",
"endMustNotHaveToQuestion": "حالت پایان نباید سوال مبدا داشته باشد.",
"endMustNotHaveCondition": "حالت پایان نباید شرط داشته باشد.",
"invalidTransition": "مسیر اشتباه است!",
"defaultMustNotHaveCondition": "حالت پیشفرض نباید شرط داشته باشد.",
"fromQuestionMustBeMultiChoice": "سوال مبدا باید چند گزینه ای باشد.",
"invalidChoiceCondition": "شرط گزینه اشتباه است!",
"choiceNotBelongToQuestion": "گزینه متعلق به سوال نیست.",
"fromQuestionMustBeNumeric": "سوال مبدا باید از نوع عددی باشد.",
"invalidNumberRange": "بازه عددی اشتباه است!",
"startAlreadyExists": "حالت شروع وجود دارد.",
"endAlreadyExists": "حالت پایان وجود دارد.",
"defaultAlreadyExists": "حالت پیشفرض وجود دارد.",
"duplicateTransition": "مسیر تکراری است!"
} }
}, },
"messages": { "messages": {
@@ -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<QuestionTransition>,
) {}
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,
);
}
}
}
@@ -1,6 +1,6 @@
import { HttpStatus, Injectable } from '@nestjs/common'; import { HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import type { Repository } from 'typeorm';
import { TestsService } from '@/modules/tests/providers/tests.service'; import { TestsService } from '@/modules/tests/providers/tests.service';
import { QuestionTransition } from '../../../entities/question-transition.entity'; import { QuestionTransition } from '../../../entities/question-transition.entity';
import { Question } from '@/modules/questions/entities/question.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 { QuestionsQueryProvider } from '../../questions-query.provider';
import { TransitionValidateOptions } from '../../../interfaces/transition-validate-options.interface'; import { TransitionValidateOptions } from '../../../interfaces/transition-validate-options.interface';
import { QuestionTransitionTypeValidator } from '../../../validators/question-transition-type.validator'; import { QuestionTransitionTypeValidator } from '../../../validators/question-transition-type.validator';
import { CheckTransitionDBProvider } from './check-transition-db.provider';
@Injectable() @Injectable()
export class QuestionTransitionValidatorProvider { export class QuestionTransitionValidatorProvider {
constructor( constructor(
/**
* Inject Transitions Repository
*/
@InjectRepository(QuestionTransition) @InjectRepository(QuestionTransition)
private readonly transitionsRepository: Repository<QuestionTransition>, private readonly transitionsRepository: Repository<QuestionTransition>,
/**
* Inject Tests Service
*/
private readonly testsService: TestsService, private readonly testsService: TestsService,
/**
* Inject Questions Query Provider
*/
private readonly questionsQueryProvider: QuestionsQueryProvider, private readonly questionsQueryProvider: QuestionsQueryProvider,
/**
* Inject CheckTransitionDB Provider
*/
private readonly checkTransitionDBProvider: CheckTransitionDBProvider,
) {} ) {}
public async validate( public async validate(
@@ -120,6 +135,10 @@ export class QuestionTransitionValidatorProvider {
}; };
QuestionTransitionTypeValidator.validateTransitionType(options); QuestionTransitionTypeValidator.validateTransitionType(options);
await this.checkTransitionDBProvider.validate(options, transitionId);
return options;
} }
private validateQuestions( private validateQuestions(
+11
View File
@@ -15,6 +15,8 @@ import { QuestionRangeRuleService } from './providers/range-rule/question-range-
import { QuestionRangeRuleCommandProvider } from './providers/range-rule/question-range-rule-command.provider'; import { QuestionRangeRuleCommandProvider } from './providers/range-rule/question-range-rule-command.provider';
import { QuestionRangeRuleQueryProvider } from './providers/range-rule/question-range-rule-query.provider'; import { QuestionRangeRuleQueryProvider } from './providers/range-rule/question-range-rule-query.provider';
import { QuestionTransition } from './entities/question-transition.entity'; 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({ @Module({
imports: [ imports: [
@@ -28,15 +30,24 @@ import { QuestionTransition } from './entities/question-transition.entity';
], ],
controllers: [QuestionsController], controllers: [QuestionsController],
providers: [ providers: [
// Questsions
QuestionsCommandProvider, QuestionsCommandProvider,
QuestionsQueryProvider, QuestionsQueryProvider,
QuestionsService, QuestionsService,
// Choices
ChoicesCommandProvider, ChoicesCommandProvider,
ChoicesQueryProvider, ChoicesQueryProvider,
ChoicesService, ChoicesService,
// Question Range Rules
QuestionRangeRuleCommandProvider, QuestionRangeRuleCommandProvider,
QuestionRangeRuleQueryProvider, QuestionRangeRuleQueryProvider,
QuestionRangeRuleService, QuestionRangeRuleService,
// Question Transitions
CheckTransitionDBProvider,
QuestionTransitionValidatorProvider,
], ],
exports: [QuestionsService, ChoicesService, QuestionRangeRuleService], exports: [QuestionsService, ChoicesService, QuestionRangeRuleService],
}) })
@@ -5,6 +5,7 @@ import { NumericCondition } from '@/modules/questions/interfaces/numeric-conditi
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';
import { HttpStatus } from '@nestjs/common'; import { HttpStatus } from '@nestjs/common';
import { NumericMetadata } from '../interfaces/numeric-metadata.interface';
export class QuestionTransitionTypeValidator { export class QuestionTransitionTypeValidator {
public static validateTransitionType(options: TransitionValidateOptions) { public static validateTransitionType(options: TransitionValidateOptions) {
@@ -34,21 +35,21 @@ export class QuestionTransitionTypeValidator {
private static validateStart(options: TransitionValidateOptions) { private static validateStart(options: TransitionValidateOptions) {
if (options.fromQuestion) { if (options.fromQuestion) {
throw new AppException( throw new AppException(
'questionTransitions.errors.startMustNotHaveFromQuestion', 'questions.errors.questionTransition.startMustNotHaveFromQuestion',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
if (!options.toQuestion) { if (!options.toQuestion) {
throw new AppException( throw new AppException(
'questionTransitions.errors.startMustHaveToQuestion', 'questions.errors.questionTransition.startMustHaveToQuestion',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
if (options.conditionValue) { if (options.conditionValue) {
throw new AppException( throw new AppException(
'questionTransitions.errors.startMustNotHaveCondition', 'questions.errors.questionTransition.startMustNotHaveCondition',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
@@ -57,21 +58,21 @@ export class QuestionTransitionTypeValidator {
private static validateEnd(options: TransitionValidateOptions) { private static validateEnd(options: TransitionValidateOptions) {
if (!options.fromQuestion) { if (!options.fromQuestion) {
throw new AppException( throw new AppException(
'questionTransitions.errors.endMustHaveFromQuestion', 'questions.errors.questionTransition.endMustHaveFromQuestion',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
if (options.toQuestion) { if (options.toQuestion) {
throw new AppException( throw new AppException(
'questionTransitions.errors.endMustNotHaveToQuestion', 'questions.errors.questionTransition.endMustNotHaveToQuestion',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
if (options.conditionValue) { if (options.conditionValue) {
throw new AppException( throw new AppException(
'questionTransitions.errors.endMustNotHaveCondition', 'questions.errors.questionTransition.endMustNotHaveCondition',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
@@ -80,14 +81,14 @@ export class QuestionTransitionTypeValidator {
private static validateDefault(options: TransitionValidateOptions) { private static validateDefault(options: TransitionValidateOptions) {
if (!options.fromQuestion || !options.toQuestion) { if (!options.fromQuestion || !options.toQuestion) {
throw new AppException( throw new AppException(
'questionTransitions.errors.invalidTransition', 'questions.errors.questionTransition.invalidTransition',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
if (options.conditionValue) { if (options.conditionValue) {
throw new AppException( throw new AppException(
'questionTransitions.errors.defaultMustNotHaveCondition', 'questions.errors.questionTransition.defaultMustNotHaveCondition',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
@@ -98,14 +99,14 @@ export class QuestionTransitionTypeValidator {
if (!fromQuestion || !toQuestion) { if (!fromQuestion || !toQuestion) {
throw new AppException( throw new AppException(
'questionTransitions.errors.invalidTransition', 'questions.errors.questionTransition.invalidTransition',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
if (fromQuestion.type !== QuestionType.MULTI_CHOICE) { if (fromQuestion.type !== QuestionType.MULTI_CHOICE) {
throw new AppException( throw new AppException(
'questionTransitions.errors.fromQuestionMustBeMultiChoice', 'questions.errors.questionTransition.fromQuestionMustBeMultiChoice',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
@@ -114,7 +115,7 @@ export class QuestionTransitionTypeValidator {
if (typeof choiceId !== 'number') { if (typeof choiceId !== 'number') {
throw new AppException( throw new AppException(
'questionTransitions.errors.invalidChoiceCondition', 'questions.errors.questionTransition.invalidChoiceCondition',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
@@ -125,7 +126,7 @@ export class QuestionTransitionTypeValidator {
if (!choiceExists) { if (!choiceExists) {
throw new AppException( throw new AppException(
'questionTransitions.errors.choiceNotBelongToQuestion', 'questions.errors.questionTransition.choiceNotBelongToQuestion',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
@@ -138,14 +139,14 @@ export class QuestionTransitionTypeValidator {
if (!fromQuestion || !toQuestion) { if (!fromQuestion || !toQuestion) {
throw new AppException( throw new AppException(
'questionTransitions.errors.invalidTransition', 'questions.errors.questionTransition.invalidTransition',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
if (fromQuestion.type !== QuestionType.NUMERIC) { if (fromQuestion.type !== QuestionType.NUMERIC) {
throw new AppException( throw new AppException(
'questionTransitions.errors.fromQuestionMustBeNumeric', 'questions.errors.questionTransition.fromQuestionMustBeNumeric',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
@@ -158,16 +159,27 @@ export class QuestionTransitionTypeValidator {
typeof condition.max !== 'number' typeof condition.max !== 'number'
) { ) {
throw new AppException( throw new AppException(
'questionTransitions.errors.invalidNumberRange', 'questions.errors.questionTransition.invalidNumberRange',
HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST,
); );
} }
if (condition.min > condition.max) { if (condition.min > condition.max) {
throw new AppException( 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, HttpStatus.BAD_REQUEST,
); );
} }
} }
} }
}