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
@@ -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 { 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<QuestionTransition>,
/**
* 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(
+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 { 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],
})
@@ -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,
);
}
}
}
}