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 { Choice } from './choice.entity';
|
||||||
import { UNIQUE_TEST_ORDER_IDX } from '../constants';
|
import { UNIQUE_TEST_ORDER_IDX } from '../constants';
|
||||||
import { QuestionRangeRule } from './question-range-rule.entity';
|
import { QuestionRangeRule } from './question-range-rule.entity';
|
||||||
|
import { QuestionTransition } from './question-transition.entity';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true })
|
@Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true })
|
||||||
@@ -54,6 +55,12 @@ export class Question {
|
|||||||
@OneToMany(() => Choice, (choice) => choice.question)
|
@OneToMany(() => Choice, (choice) => choice.question)
|
||||||
choices!: Array<Choice>;
|
choices!: Array<Choice>;
|
||||||
|
|
||||||
|
@OneToMany(() => QuestionTransition, (transition) => transition.fromQuestion)
|
||||||
|
outgoingTransitions!: Array<QuestionTransition>;
|
||||||
|
|
||||||
|
@OneToMany(() => QuestionTransition, (transition) => transition.toQuestion)
|
||||||
|
incomingTransition!: Array<QuestionTransition>;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
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 { QuestionRangeRuleService } from './providers/question-range-rule.service';
|
||||||
import { QuestionRangeRuleCommandProvider } from './providers/question-range-rule-command.provider';
|
import { QuestionRangeRuleCommandProvider } from './providers/question-range-rule-command.provider';
|
||||||
import { QuestionRangeRuleQueryProvider } from './providers/question-range-rule-query.provider';
|
import { QuestionRangeRuleQueryProvider } from './providers/question-range-rule-query.provider';
|
||||||
|
import { QuestionTransition } from './entities/question-transition.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([Question, QuestionRangeRule, Choice]),
|
TypeOrmModule.forFeature([
|
||||||
|
Question,
|
||||||
|
QuestionRangeRule,
|
||||||
|
Choice,
|
||||||
|
QuestionTransition,
|
||||||
|
]),
|
||||||
TestsModule,
|
TestsModule,
|
||||||
],
|
],
|
||||||
controllers: [QuestionsController],
|
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