From afb5045cf0a7e152dc422198030b8e2bc41e1d7d Mon Sep 17 00:00:00 2001 From: radmehr Date: Sat, 20 Jun 2026 07:48:33 +0330 Subject: [PATCH] feat: change scoring strategy --- src/i18n/en/questions.json | 9 +- src/i18n/fa/questions.json | 9 +- .../questions/admin-questions.controller.ts | 47 +++++ .../admin-questions.delete.endpoints.http | 3 + .../http/admin-questions.get.endpoints.http | 6 + .../http/admin-questions.patch.endpoints.http | 9 + .../http/admin-questions.post.endpoints.http | 11 + .../decorators/is-valid-range.decorator.ts | 6 + .../questions/dtos/create-choice.dto.ts | 8 + .../dtos/create-question-range-rule.dto.ts | 23 ++ .../questions/dtos/create-question.dto.ts | 8 + .../questions/dtos/find-choices.dto.ts | 4 + .../dtos/find-question-range-rule.dto.ts | 30 +++ .../questions/dtos/find-questions.dto.ts | 5 + .../dtos/update-question-range-rule.dto.ts | 6 + .../questions/dtos/update-question.dto.ts | 8 + .../questions/entities/choice.entity.ts | 3 + .../entities/question-range-rule.entity.ts | 22 ++ .../questions/entities/question.entity.ts | 7 + .../questions/enums/choice-sort-field.enum.ts | 1 + .../question-range-rule-sort-field.enum.ts | 7 + .../enums/question-sort-field.enum.ts | 1 + .../helpers/filter-choices.helper.ts | 4 + .../filter-question-range-rules.helper.ts | 25 +++ .../helpers/filter-questions.helper.ts | 4 + .../question-range-rule-command.provider.ts | 196 ++++++++++++++++++ .../question-range-rule-query.provider.ts | 84 ++++++++ .../providers/question-range-rule.service.ts | 51 +++++ src/modules/questions/questions.module.ts | 14 +- .../validators/question-range.validator.ts | 28 +++ .../scoring/entities/rule-set.entity.ts | 21 -- src/modules/scoring/scoring.module.ts | 3 +- src/modules/tests/entities/test.entity.ts | 5 - 33 files changed, 634 insertions(+), 34 deletions(-) create mode 100644 src/modules/questions/decorators/is-valid-range.decorator.ts create mode 100644 src/modules/questions/dtos/create-question-range-rule.dto.ts create mode 100644 src/modules/questions/dtos/find-question-range-rule.dto.ts create mode 100644 src/modules/questions/dtos/update-question-range-rule.dto.ts create mode 100644 src/modules/questions/entities/question-range-rule.entity.ts create mode 100644 src/modules/questions/enums/question-range-rule-sort-field.enum.ts create mode 100644 src/modules/questions/helpers/filter-question-range-rules.helper.ts create mode 100644 src/modules/questions/providers/question-range-rule-command.provider.ts create mode 100644 src/modules/questions/providers/question-range-rule-query.provider.ts create mode 100644 src/modules/questions/providers/question-range-rule.service.ts create mode 100644 src/modules/questions/validators/question-range.validator.ts delete mode 100644 src/modules/scoring/entities/rule-set.entity.ts diff --git a/src/i18n/en/questions.json b/src/i18n/en/questions.json index 8e12b65..7e6ec1f 100644 --- a/src/i18n/en/questions.json +++ b/src/i18n/en/questions.json @@ -9,10 +9,15 @@ "question": "Question", "text": "Text", "value": "Value", - "choice": "Choice" + "choice": "Choice", + "score": "Score", + "weight": "Weight", + "rangeRule": "Range Rule" }, "errors": { - "uniqueOrderConstraint": "Order value should be unique." + "uniqueOrderConstraint": "Order value should be unique.", + "rangeRuleMinMaxConstraint": "min value should be less than max.", + "rangeRuleIntersectionConstraint": "{min} to {max} range has some intersection with other ranges." }, "messages": { "createSuccess": "Question Created Successfully.", diff --git a/src/i18n/fa/questions.json b/src/i18n/fa/questions.json index 81b86be..21b210f 100644 --- a/src/i18n/fa/questions.json +++ b/src/i18n/fa/questions.json @@ -9,10 +9,15 @@ "question": "سوال", "text": "متن", "value": "مقدار", - "choice": "گزینه" + "choice": "گزینه", + "score": "نمره", + "weight": "وزن", + "rangeRule": "قانون بازه ای" }, "errors": { - "uniqueOrderConstraint": "مقدار اولویت تکراری است." + "uniqueOrderConstraint": "مقدار اولویت تکراری است.", + "rangeRuleMinMaxConstraint": "مقدار min باید از max کوچکتر باشد.", + "rangeRuleIntersectionConstraint": "بازه {min} تا {max} با دیگر بازه های تعریف شده تداخل دارد." }, "messages": { "createSuccess": "سوال با موفقیت ایجاد شد.", diff --git a/src/modules/admin/questions/admin-questions.controller.ts b/src/modules/admin/questions/admin-questions.controller.ts index 0b95c49..2d55d5c 100644 --- a/src/modules/admin/questions/admin-questions.controller.ts +++ b/src/modules/admin/questions/admin-questions.controller.ts @@ -1,8 +1,12 @@ import { Role } from '@/common/enums/roles.enum'; import { Roles } from '@/modules/auth/decorators/roles.decorator'; +import { CreateQuestionRangeRuleDTO } from '@/modules/questions/dtos/create-question-range-rule.dto'; import { CreateQuestionDTO } from '@/modules/questions/dtos/create-question.dto'; +import { FindQuestionRangeRuleDTO } from '@/modules/questions/dtos/find-question-range-rule.dto'; import { FindQuestionsDTO } from '@/modules/questions/dtos/find-questions.dto'; +import { UpdateQuestionRangeRuleDTO } from '@/modules/questions/dtos/update-question-range-rule.dto'; import { UpdateQuestionDTO } from '@/modules/questions/dtos/update-question.dto'; +import { QuestionRangeRuleService } from '@/modules/questions/providers/question-range-rule.service'; import { QuestionsService } from '@/modules/questions/providers/questions.service'; import { Body, @@ -26,6 +30,11 @@ export class AdminQuestionsController { * Inject Questions Service Provider */ private readonly questionsService: QuestionsService, + + /** + * Inject Questions Range Rules Service + */ + private readonly questionRangeRulesService: QuestionRangeRuleService, ) {} @Post() @@ -38,6 +47,44 @@ export class AdminQuestionsController { return await this.questionsService.findAllForAdmin(query); } + @Post('range-rules') + public async createQuestionRangeRule( + @Body() createQuestionRangeRuleDto: CreateQuestionRangeRuleDTO, + ) { + return await this.questionRangeRulesService.createOne( + createQuestionRangeRuleDto, + ); + } + + @Get('range-rules') + public async getAllQuestionRangeRules( + @Query() query: FindQuestionRangeRuleDTO, + ) { + return await this.questionRangeRulesService.findAllForAdmin(query); + } + + @Get('range-rules/:id') + public async getQuestionRangeRuleById(@Param('id', ParseIntPipe) id: number) { + return await this.questionRangeRulesService.findOneForAdmin(id); + } + + @Patch('range-rules/:id') + public async updateQuestionRangeRule( + @Param('id', ParseIntPipe) id: number, + @Body() updateQuestionRangeRuleDto: UpdateQuestionRangeRuleDTO, + ) { + return await this.questionRangeRulesService.updateOne( + id, + updateQuestionRangeRuleDto, + ); + } + + @Delete('range-rules/:id') + @HttpCode(HttpStatus.NO_CONTENT) + public async deleteQuestionRangeRule(@Param('id', ParseIntPipe) id: number) { + return await this.questionRangeRulesService.deleteOne(id); + } + @Get(':id') public async getQuestionById(@Param('id', ParseIntPipe) id: number) { return await this.questionsService.findOneForAdmin(id); diff --git a/src/modules/admin/questions/http/admin-questions.delete.endpoints.http b/src/modules/admin/questions/http/admin-questions.delete.endpoints.http index ed4a6f5..393e3c8 100644 --- a/src/modules/admin/questions/http/admin-questions.delete.endpoints.http +++ b/src/modules/admin/questions/http/admin-questions.delete.endpoints.http @@ -1,2 +1,5 @@ DELETE {{baseUrl}}/admin/questions/11 Authorization: Bearer {{$global.accessToken}} + +DELETE {{baseUrl}}/admin/questions/range-rules/3 +Authorization: Bearer {{$global.accessToken}} diff --git a/src/modules/admin/questions/http/admin-questions.get.endpoints.http b/src/modules/admin/questions/http/admin-questions.get.endpoints.http index 6a46fb5..199081e 100644 --- a/src/modules/admin/questions/http/admin-questions.get.endpoints.http +++ b/src/modules/admin/questions/http/admin-questions.get.endpoints.http @@ -3,3 +3,9 @@ Authorization: Bearer {{$global.accessToken}} GET {{baseUrl}}/admin/questions/1 Authorization: Bearer {{$global.accessToken}} + +GET {{baseUrl}}/admin/questions/range-rules?limit=1&page=2&sortBy=id&order=ASC +Authorization: Bearer {{$global.accessToken}} + +GET {{baseUrl}}/admin/questions/range-rules/2 +Authorization: Bearer {{$global.accessToken}} diff --git a/src/modules/admin/questions/http/admin-questions.patch.endpoints.http b/src/modules/admin/questions/http/admin-questions.patch.endpoints.http index 362be4c..a7ed8ea 100644 --- a/src/modules/admin/questions/http/admin-questions.patch.endpoints.http +++ b/src/modules/admin/questions/http/admin-questions.patch.endpoints.http @@ -5,3 +5,12 @@ Content-Type: application/json { "test": 4 } + +PATCH {{baseUrl}}/admin/questions/range-rules/1 +Authorization: Bearer {{$global.accessToken}} +Content-Type: application/json + +{ + "min": 6 +} + diff --git a/src/modules/admin/questions/http/admin-questions.post.endpoints.http b/src/modules/admin/questions/http/admin-questions.post.endpoints.http index 2e3bf82..07bdf37 100644 --- a/src/modules/admin/questions/http/admin-questions.post.endpoints.http +++ b/src/modules/admin/questions/http/admin-questions.post.endpoints.http @@ -9,4 +9,15 @@ Content-Type: application/json "required": true, "type": "numeric", "metadata": { "min": 1, "max": 100 } +} + +POST {{baseUrl}}/admin/questions/range-rules +Authorization: Bearer {{$global.accessToken}} +Content-Type: application/json + +{ + "question": 8, + "min": 1, + "max": 5, + "score": 7 } \ No newline at end of file diff --git a/src/modules/questions/decorators/is-valid-range.decorator.ts b/src/modules/questions/decorators/is-valid-range.decorator.ts new file mode 100644 index 0000000..fecd264 --- /dev/null +++ b/src/modules/questions/decorators/is-valid-range.decorator.ts @@ -0,0 +1,6 @@ +import { Validate } from 'class-validator'; +import { QuestionRangeValidator } from '../validators/question-range.validator'; + +export const IsValidRange = () => { + return Validate(QuestionRangeValidator); +}; diff --git a/src/modules/questions/dtos/create-choice.dto.ts b/src/modules/questions/dtos/create-choice.dto.ts index c424c5b..478abfd 100644 --- a/src/modules/questions/dtos/create-choice.dto.ts +++ b/src/modules/questions/dtos/create-choice.dto.ts @@ -42,4 +42,12 @@ export class CreateChoiceDTO { }), }) order!: number; + + @IsNumber() + @IsNotEmpty({ + message: t('validation.requiredField', { + field: '$t(questions.fields.score)', + }), + }) + score!: number; } diff --git a/src/modules/questions/dtos/create-question-range-rule.dto.ts b/src/modules/questions/dtos/create-question-range-rule.dto.ts new file mode 100644 index 0000000..0947322 --- /dev/null +++ b/src/modules/questions/dtos/create-question-range-rule.dto.ts @@ -0,0 +1,23 @@ +import { IsNotEmpty, IsNumber } from 'class-validator'; +import { IsValidRange } from '../decorators/is-valid-range.decorator'; + +export class CreateQuestionRangeRuleDTO { + @IsNumber() + @IsNotEmpty() + question!: number; + + @IsNumber() + @IsNotEmpty() + min!: number; + + @IsNumber() + @IsNotEmpty() + max!: number; + + @IsNumber() + @IsNotEmpty() + score!: number; + + @IsValidRange() + private readonly _questionRangeValidation!: never; +} diff --git a/src/modules/questions/dtos/create-question.dto.ts b/src/modules/questions/dtos/create-question.dto.ts index 856f9f8..6286f37 100644 --- a/src/modules/questions/dtos/create-question.dto.ts +++ b/src/modules/questions/dtos/create-question.dto.ts @@ -50,6 +50,14 @@ export class CreateQuestionDTO { }) order!: number; + @IsPositive({ + message: t('validation.wrongField', { + field: '$t(questions.fields.weight)', + }), + }) + @IsOptional() + weight?: number; + @IsBoolean() @IsOptional() required?: boolean; diff --git a/src/modules/questions/dtos/find-choices.dto.ts b/src/modules/questions/dtos/find-choices.dto.ts index ac6cda8..dd9bae8 100644 --- a/src/modules/questions/dtos/find-choices.dto.ts +++ b/src/modules/questions/dtos/find-choices.dto.ts @@ -24,6 +24,10 @@ export class FindChoicesDTO extends PartialType(PaginationQueryDTO) { @IsOptional() choiceOrder?: number; + @IsNumber() + @IsOptional() + score?: number; + @IsEnum(ChoiceSortField) @IsOptional() sortBy?: ChoiceSortField; diff --git a/src/modules/questions/dtos/find-question-range-rule.dto.ts b/src/modules/questions/dtos/find-question-range-rule.dto.ts new file mode 100644 index 0000000..0848dfb --- /dev/null +++ b/src/modules/questions/dtos/find-question-range-rule.dto.ts @@ -0,0 +1,30 @@ +import { PaginationQueryDTO } from '@/common/dtos/pagination-query.dto'; +import { PartialType } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional } from 'class-validator'; +import { QuestionRangeRuleSortField } from '../enums/question-range-rule-sort-field.enum'; + +export class FindQuestionRangeRuleDTO extends PartialType(PaginationQueryDTO) { + @IsNumber() + @IsOptional() + id?: number; + + @IsNumber() + @IsOptional() + question?: number; + + @IsNumber() + @IsOptional() + min?: number; + + @IsNumber() + @IsOptional() + max?: number; + + @IsNumber() + @IsOptional() + score?: number; + + @IsEnum(QuestionRangeRuleSortField) + @IsOptional() + sortBy?: QuestionRangeRuleSortField; +} diff --git a/src/modules/questions/dtos/find-questions.dto.ts b/src/modules/questions/dtos/find-questions.dto.ts index a5d20be..bf88570 100644 --- a/src/modules/questions/dtos/find-questions.dto.ts +++ b/src/modules/questions/dtos/find-questions.dto.ts @@ -6,6 +6,7 @@ import { IsEnum, IsNumber, IsOptional, + IsPositive, IsString, } from 'class-validator'; import { QuestionSortField } from '../enums/question-sort-field.enum'; @@ -31,6 +32,10 @@ export class FindQuestionsDTO extends PartialType(PaginationQueryDTO) { @IsOptional() questionOrder?: number; + @IsPositive() + @IsOptional() + weight?: number; + @IsBoolean() @IsOptional() required?: boolean; diff --git a/src/modules/questions/dtos/update-question-range-rule.dto.ts b/src/modules/questions/dtos/update-question-range-rule.dto.ts new file mode 100644 index 0000000..90cd2ce --- /dev/null +++ b/src/modules/questions/dtos/update-question-range-rule.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateQuestionRangeRuleDTO } from './create-question-range-rule.dto'; + +export class UpdateQuestionRangeRuleDTO extends PartialType( + CreateQuestionRangeRuleDTO, +) {} diff --git a/src/modules/questions/dtos/update-question.dto.ts b/src/modules/questions/dtos/update-question.dto.ts index c4aa992..8f45b23 100644 --- a/src/modules/questions/dtos/update-question.dto.ts +++ b/src/modules/questions/dtos/update-question.dto.ts @@ -35,6 +35,14 @@ export class UpdateQuestionDTO { @IsOptional() order?: number; + @IsPositive({ + message: t('validation.wrongField', { + field: '$t(questions.fields.weight)', + }), + }) + @IsOptional() + weight?: number; + @IsBoolean() @IsOptional() required?: boolean; diff --git a/src/modules/questions/entities/choice.entity.ts b/src/modules/questions/entities/choice.entity.ts index 77ab5d4..fbaf153 100644 --- a/src/modules/questions/entities/choice.entity.ts +++ b/src/modules/questions/entities/choice.entity.ts @@ -31,4 +31,7 @@ export class Choice { @Column({ type: 'int', nullable: false }) order!: number; + + @Column({ type: 'int', nullable: false }) + score!: number; } diff --git a/src/modules/questions/entities/question-range-rule.entity.ts b/src/modules/questions/entities/question-range-rule.entity.ts new file mode 100644 index 0000000..07df0ef --- /dev/null +++ b/src/modules/questions/entities/question-range-rule.entity.ts @@ -0,0 +1,22 @@ +import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { Question } from './question.entity'; + +@Entity() +export class QuestionRangeRule { + @PrimaryGeneratedColumn() + id!: number; + + @ManyToOne(() => Question, (question) => question.rangeRules, { + onDelete: 'CASCADE', + }) + question!: Question; + + @Column({ type: 'int', nullable: false }) + min!: number; + + @Column({ type: 'int', nullable: false }) + max!: number; + + @Column({ type: 'int', nullable: false }) + score!: number; +} diff --git a/src/modules/questions/entities/question.entity.ts b/src/modules/questions/entities/question.entity.ts index 603809a..27583b3 100644 --- a/src/modules/questions/entities/question.entity.ts +++ b/src/modules/questions/entities/question.entity.ts @@ -12,6 +12,7 @@ import { QuestionType } from '../enums/question-type.enum'; 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'; @Entity() @Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true }) @@ -37,9 +38,15 @@ export class Question { @Column({ type: 'int', nullable: false }) order!: number; + @Column({ type: 'int', default: 1 }) + weight!: number; + @Column({ type: 'boolean', default: true }) required!: boolean; + @OneToMany(() => QuestionRangeRule, (rangeRule) => rangeRule.question) + rangeRules?: Array; + @Column({ type: 'jsonb', nullable: true }) metadata!: QuestionMetadata; diff --git a/src/modules/questions/enums/choice-sort-field.enum.ts b/src/modules/questions/enums/choice-sort-field.enum.ts index b00c3d4..b6c4b46 100644 --- a/src/modules/questions/enums/choice-sort-field.enum.ts +++ b/src/modules/questions/enums/choice-sort-field.enum.ts @@ -4,4 +4,5 @@ export enum ChoiceSortField { TEXT = 'text', ORDER = 'order', VALUE = 'value', + SCORE = 'score', } diff --git a/src/modules/questions/enums/question-range-rule-sort-field.enum.ts b/src/modules/questions/enums/question-range-rule-sort-field.enum.ts new file mode 100644 index 0000000..713d48a --- /dev/null +++ b/src/modules/questions/enums/question-range-rule-sort-field.enum.ts @@ -0,0 +1,7 @@ +export enum QuestionRangeRuleSortField { + ID = 'id', + QUESTION = 'question', + MIN = 'min', + MAX = 'max', + SCORE = 'score', +} diff --git a/src/modules/questions/enums/question-sort-field.enum.ts b/src/modules/questions/enums/question-sort-field.enum.ts index 8915be6..95ad59f 100644 --- a/src/modules/questions/enums/question-sort-field.enum.ts +++ b/src/modules/questions/enums/question-sort-field.enum.ts @@ -3,6 +3,7 @@ export enum QuestionSortField { TEST = 'test', TYPE = 'type', CONTENT = 'content', + WEIGHT = 'weight', ORDER = 'order', CREATED_AT = 'createdAt', } diff --git a/src/modules/questions/helpers/filter-choices.helper.ts b/src/modules/questions/helpers/filter-choices.helper.ts index 1ea3a5e..e6d2e78 100644 --- a/src/modules/questions/helpers/filter-choices.helper.ts +++ b/src/modules/questions/helpers/filter-choices.helper.ts @@ -21,5 +21,9 @@ export const filterChoices = (query: FindChoicesDTO) => { where.value = query.value; } + if (query.score !== undefined) { + where.score = query.score; + } + return where; }; diff --git a/src/modules/questions/helpers/filter-question-range-rules.helper.ts b/src/modules/questions/helpers/filter-question-range-rules.helper.ts new file mode 100644 index 0000000..1d0b4d0 --- /dev/null +++ b/src/modules/questions/helpers/filter-question-range-rules.helper.ts @@ -0,0 +1,25 @@ +import { FindOptionsWhere } from 'typeorm'; +import { FindQuestionRangeRuleDTO } from '../dtos/find-question-range-rule.dto'; +import { QuestionRangeRule } from '../entities/question-range-rule.entity'; + +export const filterQuestionRangeRules = (query: FindQuestionRangeRuleDTO) => { + const where: FindOptionsWhere = {}; + + if (query.id) { + where.id = query.id; + } + + if (query.min !== undefined) { + where.min = query.min; + } + + if (query.max !== undefined) { + where.max = query.max; + } + + if (query.score !== undefined) { + where.score = query.score; + } + + return where; +}; diff --git a/src/modules/questions/helpers/filter-questions.helper.ts b/src/modules/questions/helpers/filter-questions.helper.ts index 04f08ff..925ecdc 100644 --- a/src/modules/questions/helpers/filter-questions.helper.ts +++ b/src/modules/questions/helpers/filter-questions.helper.ts @@ -17,6 +17,10 @@ export const filterQuestions = (query: FindQuestionsDTO) => { where.order = query.questionOrder; } + if (query.weight !== undefined) { + where.weight = query.weight; + } + if (query.required !== undefined) { where.required = query.required; } diff --git a/src/modules/questions/providers/question-range-rule-command.provider.ts b/src/modules/questions/providers/question-range-rule-command.provider.ts new file mode 100644 index 0000000..78ff7b4 --- /dev/null +++ b/src/modules/questions/providers/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/question-range-rule-query.provider.ts b/src/modules/questions/providers/question-range-rule-query.provider.ts new file mode 100644 index 0000000..5c98bf3 --- /dev/null +++ b/src/modules/questions/providers/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/question-range-rule.service.ts b/src/modules/questions/providers/question-range-rule.service.ts new file mode 100644 index 0000000..4f8b9ee --- /dev/null +++ b/src/modules/questions/providers/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/questions.module.ts b/src/modules/questions/questions.module.ts index 9f3ee7c..9a9972d 100644 --- a/src/modules/questions/questions.module.ts +++ b/src/modules/questions/questions.module.ts @@ -10,9 +10,16 @@ import { QuestionsQueryProvider } from './providers/questions-query.provider'; import { ChoicesCommandProvider } from './providers/choices-command.provider'; import { ChoicesService } from './providers/choices.service'; import { ChoicesQueryProvider } from './providers/choices-query.provider'; +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'; @Module({ - imports: [TypeOrmModule.forFeature([Question, Choice]), TestsModule], + imports: [ + TypeOrmModule.forFeature([Question, QuestionRangeRule, Choice]), + TestsModule, + ], controllers: [QuestionsController], providers: [ QuestionsCommandProvider, @@ -21,7 +28,10 @@ import { ChoicesQueryProvider } from './providers/choices-query.provider'; ChoicesCommandProvider, ChoicesQueryProvider, ChoicesService, + QuestionRangeRuleCommandProvider, + QuestionRangeRuleQueryProvider, + QuestionRangeRuleService, ], - exports: [QuestionsService, ChoicesService], + exports: [QuestionsService, ChoicesService, QuestionRangeRuleService], }) export class QuestionsModule {} diff --git a/src/modules/questions/validators/question-range.validator.ts b/src/modules/questions/validators/question-range.validator.ts new file mode 100644 index 0000000..a97f64a --- /dev/null +++ b/src/modules/questions/validators/question-range.validator.ts @@ -0,0 +1,28 @@ +import { + ValidationArguments, + ValidatorConstraint, + ValidatorConstraintInterface, +} from 'class-validator'; +import { I18nContext } from 'nestjs-i18n'; + +@ValidatorConstraint({ name: 'QuestionRange', async: false }) +export class QuestionRangeValidator implements ValidatorConstraintInterface { + validate( + value: any, + validationArguments?: ValidationArguments, + ): Promise | boolean { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const obj = validationArguments?.object as any; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { min, max } = obj; + return min < max; + } + defaultMessage?(): string { + const i18n = I18nContext.current(); + + return ( + i18n?.t('questions.errors.rangeRuleMinMaxConstraint') || + 'min value should be less than max.' + ); + } +} diff --git a/src/modules/scoring/entities/rule-set.entity.ts b/src/modules/scoring/entities/rule-set.entity.ts deleted file mode 100644 index 4e725b0..0000000 --- a/src/modules/scoring/entities/rule-set.entity.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Test } from '@/modules/tests/entities/test.entity'; -import { - Column, - Entity, - JoinColumn, - OneToOne, - PrimaryGeneratedColumn, -} from 'typeorm'; - -@Entity() -export class RuleSet { - @PrimaryGeneratedColumn() - id!: number; - - @OneToOne(() => Test, (test) => test.ruleSet) - @JoinColumn() - test!: Test; - - @Column({ type: 'jsonb', nullable: false }) - config: any; -} diff --git a/src/modules/scoring/scoring.module.ts b/src/modules/scoring/scoring.module.ts index 3ce975e..9f7a2dc 100644 --- a/src/modules/scoring/scoring.module.ts +++ b/src/modules/scoring/scoring.module.ts @@ -1,9 +1,8 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ResultRange } from './entities/result-range.entity'; -import { RuleSet } from './entities/rule-set.entity'; @Module({ - imports: [TypeOrmModule.forFeature([ResultRange, RuleSet])], + imports: [TypeOrmModule.forFeature([ResultRange])], }) export class ScoringModule {} diff --git a/src/modules/tests/entities/test.entity.ts b/src/modules/tests/entities/test.entity.ts index 653322b..8841b95 100644 --- a/src/modules/tests/entities/test.entity.ts +++ b/src/modules/tests/entities/test.entity.ts @@ -4,13 +4,11 @@ import { CreateDateColumn, Entity, OneToMany, - OneToOne, PrimaryGeneratedColumn, } from 'typeorm'; import { AccessType } from '../enums/access-types.enum'; import { Question } from '@/modules/questions/entities/question.entity'; import { ResultRange } from '@/modules/scoring/entities/result-range.entity'; -import { RuleSet } from '@/modules/scoring/entities/rule-set.entity'; @Entity() @Check(` @@ -58,9 +56,6 @@ export class Test { @OneToMany(() => Question, (question) => question.test) questions!: Array; - @OneToOne(() => RuleSet, (ruleSet) => ruleSet.test) - ruleSet!: RuleSet; - @OneToMany(() => ResultRange, (range) => range.test) resultRanges!: Array;