diff --git a/src/modules/questions/dtos/create-question-transition.dto.ts b/src/modules/questions/dtos/create-question-transition.dto.ts new file mode 100644 index 0000000..3355743 --- /dev/null +++ b/src/modules/questions/dtos/create-question-transition.dto.ts @@ -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; +} diff --git a/src/modules/questions/dtos/update-question-transition.dto.ts b/src/modules/questions/dtos/update-question-transition.dto.ts new file mode 100644 index 0000000..2411830 --- /dev/null +++ b/src/modules/questions/dtos/update-question-transition.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateQuestionTransitionDTO } from './create-question-transition.dto'; + +export class UpdateQuestionTransitionDTO extends PartialType( + CreateQuestionTransitionDTO, +) {} diff --git a/src/modules/questions/entities/question-transition.entity.ts b/src/modules/questions/entities/question-transition.entity.ts new file mode 100644 index 0000000..f1eed03 --- /dev/null +++ b/src/modules/questions/entities/question-transition.entity.ts @@ -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; +} diff --git a/src/modules/questions/entities/question.entity.ts b/src/modules/questions/entities/question.entity.ts index 63fd917..225b84a 100644 --- a/src/modules/questions/entities/question.entity.ts +++ b/src/modules/questions/entities/question.entity.ts @@ -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; + @OneToMany(() => QuestionTransition, (transition) => transition.fromQuestion) + outgoingTransitions!: Array; + + @OneToMany(() => QuestionTransition, (transition) => transition.toQuestion) + incomingTransition!: Array; + @CreateDateColumn() createdAt!: Date; } diff --git a/src/modules/questions/enums/question-transition-type.enum.ts b/src/modules/questions/enums/question-transition-type.enum.ts new file mode 100644 index 0000000..9cbb82f --- /dev/null +++ b/src/modules/questions/enums/question-transition-type.enum.ts @@ -0,0 +1,7 @@ +export enum QuestionTransitionType { + START = 'start', + DEFAULT = 'default', + CHOICE = 'choice', + NUMBER_RANGE = 'number-range', + END = 'end', +} diff --git a/src/modules/questions/interfaces/numeric-condition.interface.ts b/src/modules/questions/interfaces/numeric-condition.interface.ts new file mode 100644 index 0000000..d28c235 --- /dev/null +++ b/src/modules/questions/interfaces/numeric-condition.interface.ts @@ -0,0 +1,4 @@ +export interface NumericCondition { + min: number; + max: number; +} diff --git a/src/modules/questions/interfaces/single-choice-condition.interface.ts b/src/modules/questions/interfaces/single-choice-condition.interface.ts new file mode 100644 index 0000000..d414651 --- /dev/null +++ b/src/modules/questions/interfaces/single-choice-condition.interface.ts @@ -0,0 +1,3 @@ +export interface SingleChoiceCondition { + choiceId: number; +} diff --git a/src/modules/questions/providers/question-transition-command.provider.ts b/src/modules/questions/providers/question-transition-command.provider.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/questions/providers/question-transition-query.provider.ts b/src/modules/questions/providers/question-transition-query.provider.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/questions/providers/question-transition-validator.provider.ts b/src/modules/questions/providers/question-transition-validator.provider.ts new file mode 100644 index 0000000..13461b3 --- /dev/null +++ b/src/modules/questions/providers/question-transition-validator.provider.ts @@ -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, + ) {} + + 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, + ); + } + } +} diff --git a/src/modules/questions/providers/question-transition.service.ts b/src/modules/questions/providers/question-transition.service.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/questions/questions.module.ts b/src/modules/questions/questions.module.ts index 9a9972d..b1ce017 100644 --- a/src/modules/questions/questions.module.ts +++ b/src/modules/questions/questions.module.ts @@ -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], diff --git a/src/modules/questions/types/transition-condition.type.ts b/src/modules/questions/types/transition-condition.type.ts new file mode 100644 index 0000000..fd062e3 --- /dev/null +++ b/src/modules/questions/types/transition-condition.type.ts @@ -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;