feat: add validations
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<Choice>,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Questions Repository
|
||||||
|
*/
|
||||||
|
@InjectRepository(Question)
|
||||||
|
private readonly questionsRepository: Repository<Question>,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Choice>,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Questions Repository
|
||||||
|
*/
|
||||||
|
@InjectRepository(Question)
|
||||||
|
private readonly questionsRepository: Repository<Question>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<QuestionRangeRule>,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Questions Repository
|
||||||
|
*/
|
||||||
|
@InjectRepository(Question)
|
||||||
|
private readonly questionsRepository: Repository<Question>,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void> {
|
||||||
|
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<QuestionRangeRule>;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Question>,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject QuestionRangeRules Repository
|
||||||
|
*/
|
||||||
|
@InjectRepository(QuestionRangeRule)
|
||||||
|
private readonly questionRangeRulesRepository: Repository<QuestionRangeRule>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+151
@@ -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<QuestionTransition>,
|
||||||
|
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user