diff --git a/src/modules/questions/interfaces/transition-validate-options.interface.ts b/src/modules/questions/interfaces/transition-validate-options.interface.ts new file mode 100644 index 0000000..cd11fc4 --- /dev/null +++ b/src/modules/questions/interfaces/transition-validate-options.interface.ts @@ -0,0 +1,12 @@ +import { Test } from '@/modules/tests/entities/test.entity'; +import { QuestionTransitionType } from '../enums/question-transition-type.enum'; +import { Question } from '../entities/question.entity'; +import { TransitionConditionType } from '../types/transition-condition.type'; + +export interface TransitionValidateOptions { + test: Test; + type: QuestionTransitionType; + fromQuestion: Question | null; + toQuestion: Question | null; + conditionValue?: TransitionConditionType; +} diff --git a/src/modules/questions/providers/choices/choices-command.provider.ts b/src/modules/questions/providers/choices/choices-command.provider.ts new file mode 100644 index 0000000..a18da71 --- /dev/null +++ b/src/modules/questions/providers/choices/choices-command.provider.ts @@ -0,0 +1,149 @@ +import { ConflictException, HttpStatus, Injectable } from '@nestjs/common'; +import { CreateChoiceDTO } from '../../dtos/create-choice.dto'; +import { QueryFailedError, Repository } from 'typeorm'; +import { Choice } from '../../entities/choice.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Question } from '../../entities/question.entity'; +import { UNIQUE_QUESTION_ORDER_IDX } from '../../constants'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { AppResponse } from '@/common/responses'; +import { I18nService } from 'nestjs-i18n'; +import { AppException } from '@/common/exceptions/app.exception'; +import { UpdateChoiceDTO } from '../../dtos/update-choice.dto'; + +@Injectable() +export class ChoicesCommandProvider { + constructor( + /** + * Inject Choices Repository + */ + @InjectRepository(Choice) + private readonly choicesRepository: Repository, + + /** + * Inject Questions Repository + */ + @InjectRepository(Question) + private readonly questionsRepository: Repository, + + /** + * Inject I18n Service + */ + private readonly i18nService: I18nService, + ) {} + + public async createOne(createChoiceDto: CreateChoiceDTO) { + const question = await this.questionsRepository.findOneBy({ + id: createChoiceDto.question, + }); + + if (!question) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(questions.fields.question)' }, + ); + } + + const newChoice = this.choicesRepository.create({ + ...createChoiceDto, + question, + }); + + try { + await this.choicesRepository.save(newChoice); + return new AppResponse( + newChoice, + this.i18nService.translate('common.messages.createSuccess', { + args: { entity: '$t(questions.fields.choice)' }, + }), + ); + } catch (err) { + if ( + err instanceof QueryFailedError && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (err as any).constraint === UNIQUE_QUESTION_ORDER_IDX + ) { + throw new ConflictException('questions.errors.uniqueOrderConstraint'); + } + + throw new AppTimeoutException(err); + } + } + + public async updateOne(id: number, updateChoiceDto: UpdateChoiceDTO) { + const choice = await this.choicesRepository.findOneBy({ id }); + + if (!choice) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { + entity: '$t(questions.fields.choice)', + }, + ); + } + + if (updateChoiceDto.question) { + const question = await this.questionsRepository.findOneBy({ + id: updateChoiceDto.question, + }); + + if (!question) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { + entity: '$t(questions.fields.question)', + }, + ); + } + } + + Object.assign(choice, updateChoiceDto); + + try { + const updatedChoice = await this.choicesRepository.save(choice); + return new AppResponse( + updatedChoice, + this.i18nService.translate('common.messages.updateSuccess'), + ); + } catch (err) { + if ( + err instanceof QueryFailedError && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (err as any).constraint === UNIQUE_QUESTION_ORDER_IDX + ) { + throw new ConflictException('questions.errors.uniqueOrderConstraint'); + } + + throw new AppTimeoutException(err); + } + } + + public async deleteOne(id: number) { + let choice: Choice | null = null; + + try { + choice = await this.choicesRepository.findOneBy({ id }); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!choice) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { + entity: '$t(questions.fields.choice)', + }, + ); + } + + try { + await this.choicesRepository.delete(choice.id); + } catch (err) { + throw new AppTimeoutException(err); + } + } +} diff --git a/src/modules/questions/providers/choices/choices-query.provider.ts b/src/modules/questions/providers/choices/choices-query.provider.ts new file mode 100644 index 0000000..7f4ea34 --- /dev/null +++ b/src/modules/questions/providers/choices/choices-query.provider.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@nestjs/common'; +import Utils from '@/common/utils'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { PaginatedResponse } from '@/common/responses'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Question } from '../../entities/question.entity'; +import { Repository } from 'typeorm'; +import { Choice } from '../../entities/choice.entity'; +import { FindChoicesDTO } from '../../dtos/find-choices.dto'; +import { filterChoices } from '../../helpers/filter-choices.helper'; + +@Injectable() +export class ChoicesQueryProvider { + constructor( + /** + * Inject Choices Repository + */ + @InjectRepository(Choice) + private readonly choicesRepository: Repository, + + /** + * Inject Questions Repository + */ + @InjectRepository(Question) + private readonly questionsRepository: Repository, + ) {} + + public async findAllForAdmin(query: FindChoicesDTO) { + const filters = filterChoices(query); + const { limit, page, skip, take } = + Utils.PaginationUtils.getPaginationOptions(query); + + let selectedQuestion: Question | null = null; + + if (query.question) { + try { + selectedQuestion = await this.questionsRepository.findOneBy({ + id: query.question, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + try { + const [choices, total] = await this.choicesRepository.findAndCount({ + where: { + ...filters, + ...(selectedQuestion && { + question: { + id: selectedQuestion.id, + }, + }), + }, + loadRelationIds: true, + skip, + take, + order: query.sortBy + ? { + [query.sortBy]: query.order ?? 'ASC', + } + : { id: 'DESC' }, + }); + + return new PaginatedResponse(choices, { + page, + limit, + total, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + public async findOneForAdmin(id: number) { + try { + return await this.choicesRepository.findOne({ + where: { id }, + relations: { question: true }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } +} diff --git a/src/modules/questions/providers/choices/choices.service.ts b/src/modules/questions/providers/choices/choices.service.ts new file mode 100644 index 0000000..bb363f9 --- /dev/null +++ b/src/modules/questions/providers/choices/choices.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { ChoicesCommandProvider } from './choices-command.provider'; +import { CreateChoiceDTO } from '../../dtos/create-choice.dto'; +import { UpdateChoiceDTO } from '../../dtos/update-choice.dto'; +import { FindChoicesDTO } from '../../dtos/find-choices.dto'; +import { ChoicesQueryProvider } from './choices-query.provider'; + +@Injectable() +export class ChoicesService { + constructor( + /** + * Inject Choices Command Provider + */ + private readonly choicesCommnadProvider: ChoicesCommandProvider, + + /** + * Inject Choices Query Provider + */ + private readonly choicesQueryProvider: ChoicesQueryProvider, + ) {} + + public async createOne(createChoiceDto: CreateChoiceDTO) { + return await this.choicesCommnadProvider.createOne(createChoiceDto); + } + + public async updateOne(id: number, updateChoiceDto: UpdateChoiceDTO) { + return await this.choicesCommnadProvider.updateOne(id, updateChoiceDto); + } + + public async deleteOne(id: number) { + return await this.choicesCommnadProvider.deleteOne(id); + } + + public async findAllForAdmin(query: FindChoicesDTO) { + return await this.choicesQueryProvider.findAllForAdmin(query); + } + + public async findOneForAdmin(id: number) { + return await this.choicesQueryProvider.findOneForAdmin(id); + } +} diff --git a/src/modules/questions/providers/range-rule/question-range-rule-command.provider.ts b/src/modules/questions/providers/range-rule/question-range-rule-command.provider.ts new file mode 100644 index 0000000..22177c3 --- /dev/null +++ b/src/modules/questions/providers/range-rule/question-range-rule-command.provider.ts @@ -0,0 +1,196 @@ +import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { QuestionRangeRule } from '../../entities/question-range-rule.entity'; +import { Repository } from 'typeorm'; +import { Question } from '../../entities/question.entity'; +import { I18nService } from 'nestjs-i18n'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { AppException } from '@/common/exceptions/app.exception'; +import { CreateQuestionRangeRuleDTO } from '../../dtos/create-question-range-rule.dto'; +import { AppResponse } from '@/common/responses'; +import { UpdateQuestionRangeRuleDTO } from '../../dtos/update-question-range-rule.dto'; + +@Injectable() +export class QuestionRangeRuleCommandProvider { + constructor( + /** + * Inject QuestionRangeRules Repository + */ + @InjectRepository(QuestionRangeRule) + private readonly questionRangeRulesRepository: Repository, + + /** + * Inject Questions Repository + */ + @InjectRepository(Question) + private readonly questionsRepository: Repository, + + /** + * Inject I18n Service + */ + private readonly i18nService: I18nService, + ) {} + + private async checkQuestionExistance(questionId: number) { + let question: Question | null = null; + + try { + question = await this.questionsRepository.findOneBy({ + id: questionId, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!question) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(questions.fields.question)' }, + ); + } + + return question; + } + + private async checkRangeOverlap( + question: number, + min: number, + max: number, + excludeSelf: boolean = false, + ): Promise { + const queryBuilder = this.questionRangeRulesRepository + .createQueryBuilder('rule') + .where('rule.questionId = :question', { question }) + .andWhere( + '(rule.min BETWEEN :min AND :max) OR (rule.max BETWEEN :min AND :max) OR (:min BETWEEN rule.min AND rule.max) OR (:max BETWEEN rule.min AND rule.max)', + { min, max }, + ); + + let overlappingRules: Array; + + try { + overlappingRules = await queryBuilder.getMany(); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (overlappingRules.length > (excludeSelf ? 1 : 0)) { + throw new AppException( + 'questions.errors.rangeRuleIntersectionConstraint', + HttpStatus.CONFLICT, + { min, max }, + ); + } + } + + private async checkRangeRuleExistance(rangeRuleId: number) { + let questionRangeRule: QuestionRangeRule | null = null; + + try { + questionRangeRule = await this.questionRangeRulesRepository.findOne({ + where: { + id: rangeRuleId, + }, + relations: { + question: true, + }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!questionRangeRule) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(questions.fields.rangeRule)' }, + ); + } + + return questionRangeRule; + } + + public async createOne( + createQuestionRangeRuleDto: CreateQuestionRangeRuleDTO, + ) { + const question = await this.checkQuestionExistance( + createQuestionRangeRuleDto.question, + ); + + await this.checkRangeOverlap( + question.id, + createQuestionRangeRuleDto.min, + createQuestionRangeRuleDto.max, + ); + + const newQuestionRangeRule = this.questionRangeRulesRepository.create({ + ...createQuestionRangeRuleDto, + question, + }); + + try { + await this.questionRangeRulesRepository.save(newQuestionRangeRule); + return new AppResponse( + newQuestionRangeRule, + this.i18nService.translate('common.messages.createSuccess', { + args: { entity: '$t(questions.fields.rangeRule)' }, + }), + ); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + public async updateOne( + id: number, + updateQuestionRangeRuleDto: UpdateQuestionRangeRuleDTO, + ) { + const questionRangeRule = await this.checkRangeRuleExistance(id); + + if (updateQuestionRangeRuleDto.question) { + await this.checkQuestionExistance(updateQuestionRangeRuleDto.question); + } + + const questionId = + updateQuestionRangeRuleDto.question !== undefined + ? updateQuestionRangeRuleDto.question + : questionRangeRule.question.id; + + const min = + updateQuestionRangeRuleDto.min !== undefined + ? updateQuestionRangeRuleDto.min + : questionRangeRule.min; + + const max = + updateQuestionRangeRuleDto.max !== undefined + ? updateQuestionRangeRuleDto.max + : questionRangeRule.max; + + if (min >= max) { + throw new BadRequestException( + 'questions.errors.rangeRuleMinMaxConstraint', + ); + } + + await this.checkRangeOverlap(questionId, min, max, true); + + Object.assign(questionRangeRule, updateQuestionRangeRuleDto); + + try { + return await this.questionRangeRulesRepository.save(questionRangeRule); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + public async deleteOne(id: number) { + const questionRangeRule = await this.checkRangeRuleExistance(id); + + try { + await this.questionRangeRulesRepository.delete(questionRangeRule.id); + } catch (err) { + throw new AppTimeoutException(err); + } + } +} diff --git a/src/modules/questions/providers/range-rule/question-range-rule-query.provider.ts b/src/modules/questions/providers/range-rule/question-range-rule-query.provider.ts new file mode 100644 index 0000000..1c8abd2 --- /dev/null +++ b/src/modules/questions/providers/range-rule/question-range-rule-query.provider.ts @@ -0,0 +1,84 @@ +import { Injectable } from '@nestjs/common'; +import { FindQuestionRangeRuleDTO } from '../../dtos/find-question-range-rule.dto'; +import { filterQuestionRangeRules } from '../../helpers/filter-question-range-rules.helper'; +import Utils from '@/common/utils'; +import { Question } from '../../entities/question.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { QuestionRangeRule } from '../../entities/question-range-rule.entity'; +import { PaginatedResponse } from '@/common/responses'; + +@Injectable() +export class QuestionRangeRuleQueryProvider { + constructor( + /** + * Inject Questions Repository + */ + @InjectRepository(Question) + private readonly questionsRepository: Repository, + + /** + * Inject QuestionRangeRules Repository + */ + @InjectRepository(QuestionRangeRule) + private readonly questionRangeRulesRepository: Repository, + ) {} + + public async findAllForAdmin(query: FindQuestionRangeRuleDTO) { + const filters = filterQuestionRangeRules(query); + const { limit, page, skip, take } = + Utils.PaginationUtils.getPaginationOptions(query); + + let selectedQuestion: Question | null = null; + + if (query.question) { + try { + selectedQuestion = await this.questionsRepository.findOneBy({ + id: query.question, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + try { + const [questionRangeRules, total] = + await this.questionRangeRulesRepository.findAndCount({ + where: { + ...filters, + ...(selectedQuestion && { + question: selectedQuestion, + }), + }, + loadRelationIds: true, + skip, + take, + order: query.sortBy + ? { + [query.sortBy]: query.order ?? 'ASC', + } + : { id: 'DESC' }, + }); + + return new PaginatedResponse(questionRangeRules, { + page, + limit, + total, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + public async findOneForAdmin(id: number) { + try { + return await this.questionRangeRulesRepository.findOne({ + where: { id }, + relations: { question: true }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } +} diff --git a/src/modules/questions/providers/range-rule/question-range-rule.service.ts b/src/modules/questions/providers/range-rule/question-range-rule.service.ts new file mode 100644 index 0000000..337aa95 --- /dev/null +++ b/src/modules/questions/providers/range-rule/question-range-rule.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { CreateQuestionRangeRuleDTO } from '../../dtos/create-question-range-rule.dto'; +import { UpdateQuestionRangeRuleDTO } from '../../dtos/update-question-range-rule.dto'; +import { QuestionRangeRuleCommandProvider } from './question-range-rule-command.provider'; +import { FindQuestionRangeRuleDTO } from '../../dtos/find-question-range-rule.dto'; +import { QuestionRangeRuleQueryProvider } from './question-range-rule-query.provider'; + +@Injectable() +export class QuestionRangeRuleService { + constructor( + /** + * Inject QuestionRangeRule Command Provider + */ + private readonly questionRangeRuleCommandProvider: QuestionRangeRuleCommandProvider, + + /** + * Inject QuestionRangeRule Query Provider + */ + private readonly questionRangeRuleQueryProvider: QuestionRangeRuleQueryProvider, + ) {} + + public async createOne( + createQuestionRangeRuleDto: CreateQuestionRangeRuleDTO, + ) { + return await this.questionRangeRuleCommandProvider.createOne( + createQuestionRangeRuleDto, + ); + } + + public async updateOne( + id: number, + updateQuestionRangeRuleDto: UpdateQuestionRangeRuleDTO, + ) { + return await this.questionRangeRuleCommandProvider.updateOne( + id, + updateQuestionRangeRuleDto, + ); + } + + public async deleteOne(id: number) { + return await this.questionRangeRuleCommandProvider.deleteOne(id); + } + + public async findAllForAdmin(query: FindQuestionRangeRuleDTO) { + return await this.questionRangeRuleQueryProvider.findAllForAdmin(query); + } + + public async findOneForAdmin(id: number) { + return await this.questionRangeRuleQueryProvider.findOneForAdmin(id); + } +} diff --git a/src/modules/questions/providers/transition/question-transition-command.provider.ts b/src/modules/questions/providers/transition/question-transition-command.provider.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/questions/providers/transition/question-transition-query.provider.ts b/src/modules/questions/providers/transition/question-transition-query.provider.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/questions/providers/transition/question-transition.service.ts b/src/modules/questions/providers/transition/question-transition.service.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/questions/providers/transition/validations/question-transition-validator.provider.ts b/src/modules/questions/providers/transition/validations/question-transition-validator.provider.ts new file mode 100644 index 0000000..a01f768 --- /dev/null +++ b/src/modules/questions/providers/transition/validations/question-transition-validator.provider.ts @@ -0,0 +1,151 @@ +import { HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { 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'; +import { CreateQuestionTransitionDTO } from '../../../dtos/create-question-transition.dto'; +import { UpdateQuestionTransitionDTO } from '../../../dtos/update-question-transition.dto'; +import { QuestionTransitionType } from '../../../enums/question-transition-type.enum'; +import { TransitionConditionType } from '../../../types/transition-condition.type'; +import { Test } from '@/modules/tests/entities/test.entity'; +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'; + +@Injectable() +export class QuestionTransitionValidatorProvider { + constructor( + @InjectRepository(QuestionTransition) + private readonly transitionsRepository: Repository, + + private readonly testsService: TestsService, + + private readonly questionsQueryProvider: QuestionsQueryProvider, + ) {} + + public async validate( + dto: CreateQuestionTransitionDTO | UpdateQuestionTransitionDTO, + transitionId?: number, + ) { + let test: Test; + let type: QuestionTransitionType; + let fromQuestion: Question | null; + let toQuestion: Question | null; + let conditionValue: TransitionConditionType | undefined; + + if (transitionId !== undefined) { + const transition = await this.transitionsRepository.findOne({ + where: { id: transitionId }, + relations: { + test: true, + fromQuestion: true, + toQuestion: true, + }, + }); + + if (!transition) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(questions.fields.questionTransition)' }, + ); + } + + test = + dto.test !== undefined + ? await this.testsService.adminGetOne(dto.test) + : transition.test; + + type = dto.type ?? transition.type; + + if (dto.fromQuestion) { + fromQuestion = await this.questionsQueryProvider.findOneByIdForAdmin( + dto.fromQuestion, + ); + } else if (dto.fromQuestion === null) { + fromQuestion = null; + } else { + fromQuestion = transition.fromQuestion; + } + + if (dto.toQuestion) { + toQuestion = await this.questionsQueryProvider.findOneByIdForAdmin( + dto.toQuestion, + ); + } else if (dto.toQuestion === null) { + toQuestion = null; + } else { + toQuestion = transition.toQuestion; + } + + conditionValue = + dto.conditionValue !== undefined + ? dto.conditionValue + : transition.condition; + } else { + const createDto = dto as CreateQuestionTransitionDTO; + + test = await this.testsService.adminGetOne(createDto.test); + type = createDto.type; + + if (createDto.fromQuestion) { + fromQuestion = await this.questionsQueryProvider.findOneByIdForAdmin( + createDto.fromQuestion, + ); + } else { + fromQuestion = null; + } + + if (createDto.toQuestion) { + toQuestion = await this.questionsQueryProvider.findOneByIdForAdmin( + createDto.toQuestion, + ); + } else { + toQuestion = null; + } + + conditionValue = createDto.conditionValue; + } + + this.validateQuestions(test.id, fromQuestion, toQuestion); + + const options: TransitionValidateOptions = { + test, + fromQuestion, + toQuestion, + type, + conditionValue, + }; + + QuestionTransitionTypeValidator.validateTransitionType(options); + } + + private validateQuestions( + testId: number, + fromQuestion: Question | null, + toQuestion: Question | null, + ) { + if (fromQuestion && toQuestion && fromQuestion.id === toQuestion.id) { + throw new AppException( + 'questions.errors.questionTransition.sameQuestion', + HttpStatus.BAD_REQUEST, + ); + } + + if (fromQuestion && fromQuestion.test.id !== testId) { + throw new AppException( + 'questionTransitions.errors.fromQuestionNotBelongToTest', + HttpStatus.BAD_REQUEST, + ); + } + + if (toQuestion && toQuestion.test.id !== testId) { + throw new AppException( + 'questionTransitions.errors.toQuestionNotBelongToTest', + HttpStatus.BAD_REQUEST, + ); + } + } +} diff --git a/src/modules/questions/validators/question-transition-type.validator.ts b/src/modules/questions/validators/question-transition-type.validator.ts new file mode 100644 index 0000000..eb75d2c --- /dev/null +++ b/src/modules/questions/validators/question-transition-type.validator.ts @@ -0,0 +1,173 @@ +import { AppException } from '@/common/exceptions/app.exception'; +import { QuestionTransitionType } from '@/modules/questions/enums/question-transition-type.enum'; +import { QuestionType } from '@/modules/questions/enums/question-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 } from '@nestjs/common'; + +export class QuestionTransitionTypeValidator { + public static validateTransitionType(options: TransitionValidateOptions) { + switch (options.type) { + case QuestionTransitionType.START: + this.validateStart(options); + return; + + case QuestionTransitionType.END: + this.validateEnd(options); + return; + + case QuestionTransitionType.DEFAULT: + this.validateDefault(options); + return; + + case QuestionTransitionType.CHOICE: + this.validateChoiceTransition(options); + return; + + case QuestionTransitionType.NUMBER_RANGE: + this.validateNumberBetweenTransition(options); + return; + } + } + + private static validateStart(options: TransitionValidateOptions) { + if (options.fromQuestion) { + throw new AppException( + 'questionTransitions.errors.startMustNotHaveFromQuestion', + HttpStatus.BAD_REQUEST, + ); + } + + if (!options.toQuestion) { + throw new AppException( + 'questionTransitions.errors.startMustHaveToQuestion', + HttpStatus.BAD_REQUEST, + ); + } + + if (options.conditionValue) { + throw new AppException( + 'questionTransitions.errors.startMustNotHaveCondition', + HttpStatus.BAD_REQUEST, + ); + } + } + + private static validateEnd(options: TransitionValidateOptions) { + if (!options.fromQuestion) { + throw new AppException( + 'questionTransitions.errors.endMustHaveFromQuestion', + HttpStatus.BAD_REQUEST, + ); + } + + if (options.toQuestion) { + throw new AppException( + 'questionTransitions.errors.endMustNotHaveToQuestion', + HttpStatus.BAD_REQUEST, + ); + } + + if (options.conditionValue) { + throw new AppException( + 'questionTransitions.errors.endMustNotHaveCondition', + HttpStatus.BAD_REQUEST, + ); + } + } + + private static validateDefault(options: TransitionValidateOptions) { + if (!options.fromQuestion || !options.toQuestion) { + throw new AppException( + 'questionTransitions.errors.invalidTransition', + HttpStatus.BAD_REQUEST, + ); + } + + if (options.conditionValue) { + throw new AppException( + 'questionTransitions.errors.defaultMustNotHaveCondition', + HttpStatus.BAD_REQUEST, + ); + } + } + + private static validateChoiceTransition(options: TransitionValidateOptions) { + const { fromQuestion, toQuestion, conditionValue } = options; + + if (!fromQuestion || !toQuestion) { + throw new AppException( + 'questionTransitions.errors.invalidTransition', + HttpStatus.BAD_REQUEST, + ); + } + + if (fromQuestion.type !== QuestionType.MULTI_CHOICE) { + throw new AppException( + 'questionTransitions.errors.fromQuestionMustBeMultiChoice', + HttpStatus.BAD_REQUEST, + ); + } + + const choiceId = (conditionValue as SingleChoiceCondition).choiceId; + + if (typeof choiceId !== 'number') { + throw new AppException( + 'questionTransitions.errors.invalidChoiceCondition', + HttpStatus.BAD_REQUEST, + ); + } + + const choiceExists = fromQuestion.choices?.some( + (choice) => choice.id === choiceId, + ); + + if (!choiceExists) { + throw new AppException( + 'questionTransitions.errors.choiceNotBelongToQuestion', + HttpStatus.BAD_REQUEST, + ); + } + } + + private static validateNumberBetweenTransition( + options: TransitionValidateOptions, + ) { + const { fromQuestion, toQuestion, conditionValue } = options; + + if (!fromQuestion || !toQuestion) { + throw new AppException( + 'questionTransitions.errors.invalidTransition', + HttpStatus.BAD_REQUEST, + ); + } + + if (fromQuestion.type !== QuestionType.NUMERIC) { + throw new AppException( + 'questionTransitions.errors.fromQuestionMustBeNumeric', + HttpStatus.BAD_REQUEST, + ); + } + + const condition = 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, + ); + } + } +}