feat: add question transition entity and validators
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user