feat: add question transition entity and validators

This commit is contained in:
2026-07-27 16:54:49 +03:30
parent 0fb961ea20
commit bc64643332
13 changed files with 349 additions and 1 deletions
@@ -0,0 +1,46 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsInt, IsOptional, Min } from 'class-validator';
import { Type } from 'class-transformer';
import { QuestionTransitionType } from '../enums/question-transition-type.enum';
import type { TransitionConditionType } from '../types/transition-condition.type';
export class CreateQuestionTransitionDTO {
@ApiProperty()
@Type(() => Number)
@IsInt()
@Min(1)
test!: number;
@ApiProperty({
required: false,
nullable: true,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
fromQuestion?: number;
@ApiProperty({
required: false,
nullable: true,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
toQuestion?: number;
@ApiProperty({
enum: QuestionTransitionType,
})
@IsEnum(QuestionTransitionType)
type!: QuestionTransitionType;
@ApiProperty({
required: false,
nullable: true,
})
@IsOptional()
conditionValue?: TransitionConditionType;
}
@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/swagger';
import { CreateQuestionTransitionDTO } from './create-question-transition.dto';
export class UpdateQuestionTransitionDTO extends PartialType(
CreateQuestionTransitionDTO,
) {}
@@ -0,0 +1,29 @@
import { Test } from '@/modules/tests/entities/test.entity';
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
import { Question } from './question.entity';
import { QuestionTransitionType } from '../enums/question-transition-type.enum';
import type { TransitionConditionType } from '../types/transition-condition.type';
@Entity()
export class QuestionTransition {
@PrimaryGeneratedColumn()
id!: number;
@ManyToOne(() => Test, { onDelete: 'CASCADE' })
test!: Test;
@ManyToOne(() => Question, { nullable: true, onDelete: 'CASCADE' })
fromQuestion?: Question;
@ManyToOne(() => Question, { nullable: true, onDelete: 'CASCADE' })
toQuestion?: Question;
@Column({
type: 'enum',
enum: QuestionTransitionType,
})
type!: QuestionTransitionType;
@Column({ type: 'jsonb', nullable: true })
condition?: TransitionConditionType;
}
@@ -13,6 +13,7 @@ import type { QuestionMetadata } from '../types/question-metadata.type';
import { Choice } from './choice.entity';
import { UNIQUE_TEST_ORDER_IDX } from '../constants';
import { QuestionRangeRule } from './question-range-rule.entity';
import { QuestionTransition } from './question-transition.entity';
@Entity()
@Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true })
@@ -54,6 +55,12 @@ export class Question {
@OneToMany(() => Choice, (choice) => choice.question)
choices!: Array<Choice>;
@OneToMany(() => QuestionTransition, (transition) => transition.fromQuestion)
outgoingTransitions!: Array<QuestionTransition>;
@OneToMany(() => QuestionTransition, (transition) => transition.toQuestion)
incomingTransition!: Array<QuestionTransition>;
@CreateDateColumn()
createdAt!: Date;
}
@@ -0,0 +1,7 @@
export enum QuestionTransitionType {
START = 'start',
DEFAULT = 'default',
CHOICE = 'choice',
NUMBER_RANGE = 'number-range',
END = 'end',
}
@@ -0,0 +1,4 @@
export interface NumericCondition {
min: number;
max: number;
}
@@ -0,0 +1,3 @@
export interface SingleChoiceCondition {
choiceId: number;
}
@@ -0,0 +1,233 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AppException } from '@/common/exceptions/app.exception';
import { QuestionTransition } from '../entities/question-transition.entity';
import { QuestionTransitionType } from '../enums/question-transition-type.enum';
import { CreateQuestionTransitionDTO } from '../dtos/create-question-transition.dto';
import { UpdateQuestionTransitionDTO } from '../dtos/update-question-transition.dto';
import { SingleChoiceCondition } from '../interfaces/single-choice-condition.interface';
import { NumericCondition } from '../interfaces/numeric-condition.interface';
@Injectable()
export class QuestionTransitionValidatorProvider {
constructor(
/**
* Inject Question Transitions Repository
*/
@InjectRepository(QuestionTransition)
private readonly transitionsRepository: Repository<QuestionTransition>,
) {}
public async validate(
dto: CreateQuestionTransitionDTO | UpdateQuestionTransitionDTO,
transitionId?: number,
) {
this.validateCondition(dto);
await this.validateSingleStart(dto, transitionId);
await this.validateSingleEnd(dto, transitionId);
await this.validateDefaultTransition(dto, transitionId);
await this.validateDuplicateCondition(dto, transitionId);
this.validateQuestions(dto);
}
private validateCondition(
dto: CreateQuestionTransitionDTO | UpdateQuestionTransitionDTO,
) {
switch (dto.type) {
case QuestionTransitionType.DEFAULT: {
if (dto.conditionValue) {
throw new AppException(
'questionTransitions.errors.defaultMustNotHaveCondition',
HttpStatus.BAD_REQUEST,
);
}
break;
}
case QuestionTransitionType.CHOICE: {
if (
!dto.conditionValue ||
typeof (dto.conditionValue as SingleChoiceCondition).choiceId !==
'number'
) {
throw new AppException(
'questionTransitions.errors.invalidChoiceCondition',
HttpStatus.BAD_REQUEST,
);
}
break;
}
case QuestionTransitionType.NUMBER_RANGE: {
const condition = dto.conditionValue as NumericCondition;
if (
!condition ||
typeof condition.min !== 'number' ||
typeof condition.max !== 'number'
) {
throw new AppException(
'questionTransitions.errors.invalidNumberRange',
HttpStatus.BAD_REQUEST,
);
}
if (condition.min > condition.max) {
throw new AppException(
'questionTransitions.errors.invalidNumberRange',
HttpStatus.BAD_REQUEST,
);
}
break;
}
}
}
private async validateSingleStart(
dto: CreateQuestionTransitionDTO | UpdateQuestionTransitionDTO,
transitionId?: number,
) {
if (dto.type !== QuestionTransitionType.START) return;
const qb = this.transitionsRepository
.createQueryBuilder('t')
.where('t.testId = :testId', {
testId: dto.test,
})
.andWhere('t.type = :type', {
type: QuestionTransitionType.START,
});
if (transitionId) {
qb.andWhere('t.id != :transitionId', {
transitionId,
});
}
if (await qb.getExists()) {
throw new AppException(
'questionTransitions.errors.startAlreadyExists',
HttpStatus.CONFLICT,
);
}
}
private async validateSingleEnd(
dto: CreateQuestionTransitionDTO | UpdateQuestionTransitionDTO,
transitionId?: number,
) {
if (dto.type !== QuestionTransitionType.END) return;
const qb = this.transitionsRepository
.createQueryBuilder('t')
.where('t.fromQuestionId = :fromQuestionId', {
fromQuestionId: dto.fromQuestion,
})
.andWhere('t.type = :type', {
type: QuestionTransitionType.END,
});
if (transitionId) {
qb.andWhere('t.id != :transitionId', {
transitionId,
});
}
if (await qb.getExists()) {
throw new AppException(
'questionTransitions.errors.endAlreadyExists',
HttpStatus.CONFLICT,
);
}
}
private async validateDefaultTransition(
dto: CreateQuestionTransitionDTO | UpdateQuestionTransitionDTO,
transitionId?: number,
) {
if (dto.type !== QuestionTransitionType.DEFAULT) return;
const qb = this.transitionsRepository
.createQueryBuilder('t')
.where('t.fromQuestionId = :id', {
id: dto.fromQuestion,
})
.andWhere('t.type = :type', {
type: QuestionTransitionType.DEFAULT,
});
if (transitionId) {
qb.andWhere('t.id != :transitionId', {
transitionId,
});
}
const exists = await qb.getExists();
if (exists) {
throw new AppException(
'questionTransitions.errors.defaultAlreadyExists',
HttpStatus.CONFLICT,
);
}
}
private async validateDuplicateCondition(
dto: CreateQuestionTransitionDTO | UpdateQuestionTransitionDTO,
transitionId?: number,
) {
if (dto.type === QuestionTransitionType.DEFAULT || !dto.conditionValue) {
return;
}
const qb = this.transitionsRepository
.createQueryBuilder('t')
.where('t.fromQuestionId = :id', {
id: dto.fromQuestion,
})
.andWhere('t.type = :type', {
type: dto.type,
})
.andWhere('t.conditionValue = :condition', {
condition: JSON.stringify(dto.conditionValue),
});
if (transitionId) {
qb.andWhere('t.id != :transitionId', {
transitionId,
});
}
const exists = await qb.getExists();
if (exists) {
throw new AppException(
'questionTransitions.errors.duplicateTransition',
HttpStatus.CONFLICT,
);
}
}
private validateQuestions(
dto: CreateQuestionTransitionDTO | UpdateQuestionTransitionDTO,
) {
if (
dto.fromQuestion &&
dto.toQuestion &&
dto.fromQuestion === dto.toQuestion
) {
throw new AppException(
'questionTransitions.errors.sameQuestion',
HttpStatus.BAD_REQUEST,
);
}
}
}
+7 -1
View File
@@ -14,10 +14,16 @@ import { QuestionRangeRule } from './entities/question-range-rule.entity';
import { QuestionRangeRuleService } from './providers/question-range-rule.service';
import { QuestionRangeRuleCommandProvider } from './providers/question-range-rule-command.provider';
import { QuestionRangeRuleQueryProvider } from './providers/question-range-rule-query.provider';
import { QuestionTransition } from './entities/question-transition.entity';
@Module({
imports: [
TypeOrmModule.forFeature([Question, QuestionRangeRule, Choice]),
TypeOrmModule.forFeature([
Question,
QuestionRangeRule,
Choice,
QuestionTransition,
]),
TestsModule,
],
controllers: [QuestionsController],
@@ -0,0 +1,7 @@
import { NumericCondition } from '../interfaces/numeric-condition.interface';
import { SingleChoiceCondition } from '../interfaces/single-choice-condition.interface';
export type TransitionConditionType =
| SingleChoiceCondition
| NumericCondition
| null;