feat: change scoring strategy
This commit is contained in:
@@ -9,10 +9,15 @@
|
|||||||
"question": "Question",
|
"question": "Question",
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
"value": "Value",
|
"value": "Value",
|
||||||
"choice": "Choice"
|
"choice": "Choice",
|
||||||
|
"score": "Score",
|
||||||
|
"weight": "Weight",
|
||||||
|
"rangeRule": "Range Rule"
|
||||||
},
|
},
|
||||||
"errors": {
|
"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": {
|
"messages": {
|
||||||
"createSuccess": "Question Created Successfully.",
|
"createSuccess": "Question Created Successfully.",
|
||||||
|
|||||||
@@ -9,10 +9,15 @@
|
|||||||
"question": "سوال",
|
"question": "سوال",
|
||||||
"text": "متن",
|
"text": "متن",
|
||||||
"value": "مقدار",
|
"value": "مقدار",
|
||||||
"choice": "گزینه"
|
"choice": "گزینه",
|
||||||
|
"score": "نمره",
|
||||||
|
"weight": "وزن",
|
||||||
|
"rangeRule": "قانون بازه ای"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"uniqueOrderConstraint": "مقدار اولویت تکراری است."
|
"uniqueOrderConstraint": "مقدار اولویت تکراری است.",
|
||||||
|
"rangeRuleMinMaxConstraint": "مقدار min باید از max کوچکتر باشد.",
|
||||||
|
"rangeRuleIntersectionConstraint": "بازه {min} تا {max} با دیگر بازه های تعریف شده تداخل دارد."
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"createSuccess": "سوال با موفقیت ایجاد شد.",
|
"createSuccess": "سوال با موفقیت ایجاد شد.",
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { Role } from '@/common/enums/roles.enum';
|
import { Role } from '@/common/enums/roles.enum';
|
||||||
import { Roles } from '@/modules/auth/decorators/roles.decorator';
|
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 { 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 { 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 { 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 { QuestionsService } from '@/modules/questions/providers/questions.service';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@@ -26,6 +30,11 @@ export class AdminQuestionsController {
|
|||||||
* Inject Questions Service Provider
|
* Inject Questions Service Provider
|
||||||
*/
|
*/
|
||||||
private readonly questionsService: QuestionsService,
|
private readonly questionsService: QuestionsService,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inject Questions Range Rules Service
|
||||||
|
*/
|
||||||
|
private readonly questionRangeRulesService: QuestionRangeRuleService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -38,6 +47,44 @@ export class AdminQuestionsController {
|
|||||||
return await this.questionsService.findAllForAdmin(query);
|
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')
|
@Get(':id')
|
||||||
public async getQuestionById(@Param('id', ParseIntPipe) id: number) {
|
public async getQuestionById(@Param('id', ParseIntPipe) id: number) {
|
||||||
return await this.questionsService.findOneForAdmin(id);
|
return await this.questionsService.findOneForAdmin(id);
|
||||||
|
|||||||
@@ -1,2 +1,5 @@
|
|||||||
DELETE {{baseUrl}}/admin/questions/11
|
DELETE {{baseUrl}}/admin/questions/11
|
||||||
Authorization: Bearer {{$global.accessToken}}
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
|
|
||||||
|
DELETE {{baseUrl}}/admin/questions/range-rules/3
|
||||||
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
|
|||||||
@@ -3,3 +3,9 @@ Authorization: Bearer {{$global.accessToken}}
|
|||||||
|
|
||||||
GET {{baseUrl}}/admin/questions/1
|
GET {{baseUrl}}/admin/questions/1
|
||||||
Authorization: Bearer {{$global.accessToken}}
|
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}}
|
||||||
|
|||||||
@@ -5,3 +5,12 @@ Content-Type: application/json
|
|||||||
{
|
{
|
||||||
"test": 4
|
"test": 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PATCH {{baseUrl}}/admin/questions/range-rules/1
|
||||||
|
Authorization: Bearer {{$global.accessToken}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"min": 6
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,4 +9,15 @@ Content-Type: application/json
|
|||||||
"required": true,
|
"required": true,
|
||||||
"type": "numeric",
|
"type": "numeric",
|
||||||
"metadata": { "min": 1, "max": 100 }
|
"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
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { Validate } from 'class-validator';
|
||||||
|
import { QuestionRangeValidator } from '../validators/question-range.validator';
|
||||||
|
|
||||||
|
export const IsValidRange = () => {
|
||||||
|
return Validate(QuestionRangeValidator);
|
||||||
|
};
|
||||||
@@ -42,4 +42,12 @@ export class CreateChoiceDTO {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
order!: number;
|
order!: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsNotEmpty({
|
||||||
|
message: t('validation.requiredField', {
|
||||||
|
field: '$t(questions.fields.score)',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
score!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -50,6 +50,14 @@ export class CreateQuestionDTO {
|
|||||||
})
|
})
|
||||||
order!: number;
|
order!: number;
|
||||||
|
|
||||||
|
@IsPositive({
|
||||||
|
message: t('validation.wrongField', {
|
||||||
|
field: '$t(questions.fields.weight)',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
weight?: number;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export class FindChoicesDTO extends PartialType(PaginationQueryDTO) {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
choiceOrder?: number;
|
choiceOrder?: number;
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@IsOptional()
|
||||||
|
score?: number;
|
||||||
|
|
||||||
@IsEnum(ChoiceSortField)
|
@IsEnum(ChoiceSortField)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
sortBy?: ChoiceSortField;
|
sortBy?: ChoiceSortField;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
IsEnum,
|
IsEnum,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
IsString,
|
IsString,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { QuestionSortField } from '../enums/question-sort-field.enum';
|
import { QuestionSortField } from '../enums/question-sort-field.enum';
|
||||||
@@ -31,6 +32,10 @@ export class FindQuestionsDTO extends PartialType(PaginationQueryDTO) {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
questionOrder?: number;
|
questionOrder?: number;
|
||||||
|
|
||||||
|
@IsPositive()
|
||||||
|
@IsOptional()
|
||||||
|
weight?: number;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { PartialType } from '@nestjs/swagger';
|
||||||
|
import { CreateQuestionRangeRuleDTO } from './create-question-range-rule.dto';
|
||||||
|
|
||||||
|
export class UpdateQuestionRangeRuleDTO extends PartialType(
|
||||||
|
CreateQuestionRangeRuleDTO,
|
||||||
|
) {}
|
||||||
@@ -35,6 +35,14 @@ export class UpdateQuestionDTO {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
order?: number;
|
order?: number;
|
||||||
|
|
||||||
|
@IsPositive({
|
||||||
|
message: t('validation.wrongField', {
|
||||||
|
field: '$t(questions.fields.weight)',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
weight?: number;
|
||||||
|
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
|||||||
@@ -31,4 +31,7 @@ export class Choice {
|
|||||||
|
|
||||||
@Column({ type: 'int', nullable: false })
|
@Column({ type: 'int', nullable: false })
|
||||||
order!: number;
|
order!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', nullable: false })
|
||||||
|
score!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { QuestionType } from '../enums/question-type.enum';
|
|||||||
import type { QuestionMetadata } from '../types/question-metadata.type';
|
import type { QuestionMetadata } from '../types/question-metadata.type';
|
||||||
import { Choice } from './choice.entity';
|
import { Choice } from './choice.entity';
|
||||||
import { UNIQUE_TEST_ORDER_IDX } from '../constants';
|
import { UNIQUE_TEST_ORDER_IDX } from '../constants';
|
||||||
|
import { QuestionRangeRule } from './question-range-rule.entity';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true })
|
@Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true })
|
||||||
@@ -37,9 +38,15 @@ export class Question {
|
|||||||
@Column({ type: 'int', nullable: false })
|
@Column({ type: 'int', nullable: false })
|
||||||
order!: number;
|
order!: number;
|
||||||
|
|
||||||
|
@Column({ type: 'int', default: 1 })
|
||||||
|
weight!: number;
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: true })
|
@Column({ type: 'boolean', default: true })
|
||||||
required!: boolean;
|
required!: boolean;
|
||||||
|
|
||||||
|
@OneToMany(() => QuestionRangeRule, (rangeRule) => rangeRule.question)
|
||||||
|
rangeRules?: Array<QuestionRangeRule>;
|
||||||
|
|
||||||
@Column({ type: 'jsonb', nullable: true })
|
@Column({ type: 'jsonb', nullable: true })
|
||||||
metadata!: QuestionMetadata;
|
metadata!: QuestionMetadata;
|
||||||
|
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ export enum ChoiceSortField {
|
|||||||
TEXT = 'text',
|
TEXT = 'text',
|
||||||
ORDER = 'order',
|
ORDER = 'order',
|
||||||
VALUE = 'value',
|
VALUE = 'value',
|
||||||
|
SCORE = 'score',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export enum QuestionRangeRuleSortField {
|
||||||
|
ID = 'id',
|
||||||
|
QUESTION = 'question',
|
||||||
|
MIN = 'min',
|
||||||
|
MAX = 'max',
|
||||||
|
SCORE = 'score',
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export enum QuestionSortField {
|
|||||||
TEST = 'test',
|
TEST = 'test',
|
||||||
TYPE = 'type',
|
TYPE = 'type',
|
||||||
CONTENT = 'content',
|
CONTENT = 'content',
|
||||||
|
WEIGHT = 'weight',
|
||||||
ORDER = 'order',
|
ORDER = 'order',
|
||||||
CREATED_AT = 'createdAt',
|
CREATED_AT = 'createdAt',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,5 +21,9 @@ export const filterChoices = (query: FindChoicesDTO) => {
|
|||||||
where.value = query.value;
|
where.value = query.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query.score !== undefined) {
|
||||||
|
where.score = query.score;
|
||||||
|
}
|
||||||
|
|
||||||
return where;
|
return where;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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<QuestionRangeRule> = {};
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -17,6 +17,10 @@ export const filterQuestions = (query: FindQuestionsDTO) => {
|
|||||||
where.order = query.questionOrder;
|
where.order = query.questionOrder;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (query.weight !== undefined) {
|
||||||
|
where.weight = query.weight;
|
||||||
|
}
|
||||||
|
|
||||||
if (query.required !== undefined) {
|
if (query.required !== undefined) {
|
||||||
where.required = query.required;
|
where.required = query.required;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,16 @@ import { QuestionsQueryProvider } from './providers/questions-query.provider';
|
|||||||
import { ChoicesCommandProvider } from './providers/choices-command.provider';
|
import { ChoicesCommandProvider } from './providers/choices-command.provider';
|
||||||
import { ChoicesService } from './providers/choices.service';
|
import { ChoicesService } from './providers/choices.service';
|
||||||
import { ChoicesQueryProvider } from './providers/choices-query.provider';
|
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({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Question, Choice]), TestsModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Question, QuestionRangeRule, Choice]),
|
||||||
|
TestsModule,
|
||||||
|
],
|
||||||
controllers: [QuestionsController],
|
controllers: [QuestionsController],
|
||||||
providers: [
|
providers: [
|
||||||
QuestionsCommandProvider,
|
QuestionsCommandProvider,
|
||||||
@@ -21,7 +28,10 @@ import { ChoicesQueryProvider } from './providers/choices-query.provider';
|
|||||||
ChoicesCommandProvider,
|
ChoicesCommandProvider,
|
||||||
ChoicesQueryProvider,
|
ChoicesQueryProvider,
|
||||||
ChoicesService,
|
ChoicesService,
|
||||||
|
QuestionRangeRuleCommandProvider,
|
||||||
|
QuestionRangeRuleQueryProvider,
|
||||||
|
QuestionRangeRuleService,
|
||||||
],
|
],
|
||||||
exports: [QuestionsService, ChoicesService],
|
exports: [QuestionsService, ChoicesService, QuestionRangeRuleService],
|
||||||
})
|
})
|
||||||
export class QuestionsModule {}
|
export class QuestionsModule {}
|
||||||
|
|||||||
@@ -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> | 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.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { ResultRange } from './entities/result-range.entity';
|
import { ResultRange } from './entities/result-range.entity';
|
||||||
import { RuleSet } from './entities/rule-set.entity';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([ResultRange, RuleSet])],
|
imports: [TypeOrmModule.forFeature([ResultRange])],
|
||||||
})
|
})
|
||||||
export class ScoringModule {}
|
export class ScoringModule {}
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import {
|
|||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
OneToOne,
|
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { AccessType } from '../enums/access-types.enum';
|
import { AccessType } from '../enums/access-types.enum';
|
||||||
import { Question } from '@/modules/questions/entities/question.entity';
|
import { Question } from '@/modules/questions/entities/question.entity';
|
||||||
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
|
import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
|
||||||
import { RuleSet } from '@/modules/scoring/entities/rule-set.entity';
|
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@Check(`
|
@Check(`
|
||||||
@@ -58,9 +56,6 @@ export class Test {
|
|||||||
@OneToMany(() => Question, (question) => question.test)
|
@OneToMany(() => Question, (question) => question.test)
|
||||||
questions!: Array<Question>;
|
questions!: Array<Question>;
|
||||||
|
|
||||||
@OneToOne(() => RuleSet, (ruleSet) => ruleSet.test)
|
|
||||||
ruleSet!: RuleSet;
|
|
||||||
|
|
||||||
@OneToMany(() => ResultRange, (range) => range.test)
|
@OneToMany(() => ResultRange, (range) => range.test)
|
||||||
resultRanges!: Array<ResultRange>;
|
resultRanges!: Array<ResultRange>;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user