From 0eea63fec1b730ab8db1cbf47f8aab95a40465e0 Mon Sep 17 00:00:00 2001 From: radmehr Date: Mon, 15 Jun 2026 07:34:41 +0330 Subject: [PATCH] feat add questions module and admin routes --- src/i18n/en/questions.json | 17 ++ src/i18n/fa/questions.json | 17 ++ src/modules/admin/admin.module.ts | 16 +- .../questions/admin-questions.controller.ts | 59 ++++++ .../admin-questions.delete.endpoints.http | 2 + .../http/admin-questions.get.endpoints.http | 5 + .../http/admin-questions.patch.endpoints.http | 9 + .../http/admin-questions.post.endpoints.http | 12 ++ .../auth/http/verify-otp.post.endpoints.http | 4 +- src/modules/questions/constants/index.ts | 1 + .../questions/dtos/create-question.dto.ts | 60 ++++++ .../questions/dtos/find-questions.dto.ts | 41 ++++ .../dtos/multi-choice-metadata.dto.ts | 7 + .../questions/dtos/numeric-metadata.dto.ts | 13 ++ .../questions/dtos/update-question.dto.ts | 44 ++++ .../questions/entities/question.entity.ts | 5 +- .../enums/question-sort-field.enum.ts | 8 + .../helpers/filter-questions.helper.ts | 29 +++ .../helpers/validate-metadata.helper.ts | 34 +++ .../questions/providers/questions.service.ts | 194 ++++++++++++++++++ src/modules/questions/questions.controller.ts | 4 + src/modules/questions/questions.module.ts | 8 +- .../validators/question-metadata.validator.ts | 39 ++++ .../scoring/entities/result-range.entity.ts | 12 +- .../tests/helpers/filter-tests.helper.ts | 3 +- .../tests/providers/tests-command.provider.ts | 4 +- .../price-access-type-constraint.validator.ts | 2 - src/modules/users/entities/user.entity.ts | 10 +- 28 files changed, 636 insertions(+), 23 deletions(-) create mode 100644 src/i18n/en/questions.json create mode 100644 src/i18n/fa/questions.json create mode 100644 src/modules/admin/questions/admin-questions.controller.ts create mode 100644 src/modules/admin/questions/http/admin-questions.delete.endpoints.http create mode 100644 src/modules/admin/questions/http/admin-questions.get.endpoints.http create mode 100644 src/modules/admin/questions/http/admin-questions.patch.endpoints.http create mode 100644 src/modules/admin/questions/http/admin-questions.post.endpoints.http create mode 100644 src/modules/questions/constants/index.ts create mode 100644 src/modules/questions/dtos/create-question.dto.ts create mode 100644 src/modules/questions/dtos/find-questions.dto.ts create mode 100644 src/modules/questions/dtos/multi-choice-metadata.dto.ts create mode 100644 src/modules/questions/dtos/numeric-metadata.dto.ts create mode 100644 src/modules/questions/dtos/update-question.dto.ts create mode 100644 src/modules/questions/enums/question-sort-field.enum.ts create mode 100644 src/modules/questions/helpers/filter-questions.helper.ts create mode 100644 src/modules/questions/helpers/validate-metadata.helper.ts create mode 100644 src/modules/questions/providers/questions.service.ts create mode 100644 src/modules/questions/questions.controller.ts create mode 100644 src/modules/questions/validators/question-metadata.validator.ts diff --git a/src/i18n/en/questions.json b/src/i18n/en/questions.json new file mode 100644 index 0000000..4ad3ac1 --- /dev/null +++ b/src/i18n/en/questions.json @@ -0,0 +1,17 @@ +{ + "fields": { + "test": "Test", + "type": "Type", + "content": "Content", + "order": "Order", + "required": "Required", + "metadata": "Metadata" + }, + "errors": { + "uniqueOrderConstraint": "Order value should be unique.", + "questionNotFound": "Question not found." + }, + "messages": { + "createSuccess": "Question Created Successfully." + } +} diff --git a/src/i18n/fa/questions.json b/src/i18n/fa/questions.json new file mode 100644 index 0000000..33df268 --- /dev/null +++ b/src/i18n/fa/questions.json @@ -0,0 +1,17 @@ +{ + "fields": { + "test": "تست", + "type": "نوع", + "content": "محتوا", + "order": "اولویت", + "required": "اجباری", + "metadata": "تنظیمات" + }, + "errors": { + "uniqueOrderConstraint": "مقدار اولویت تکراری است.", + "questionNotFound": "Question not found." + }, + "messages": { + "createSuccess": "سوال با موفقیت ایجاد شد." + } +} diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 363c778..a025147 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -6,10 +6,22 @@ import { HashingModule } from '@/common/modules/hashing/hashing.module'; import { UsersModule } from '../users/users.module'; import { AdminAuthController } from './auth/admin-auth.controller'; import { AdminAuthService } from './auth/providers/admin-auth.service'; +import { QuestionsModule } from '../questions/questions.module'; +import { AdminQuestionsController } from './questions/admin-questions.controller'; @Module({ - imports: [TestsModule, AuthModule, HashingModule, UsersModule], - controllers: [AdminTestsController, AdminAuthController], + imports: [ + TestsModule, + QuestionsModule, + AuthModule, + HashingModule, + UsersModule, + ], + controllers: [ + AdminTestsController, + AdminAuthController, + AdminQuestionsController, + ], providers: [AdminAuthService], }) export class AdminModule {} diff --git a/src/modules/admin/questions/admin-questions.controller.ts b/src/modules/admin/questions/admin-questions.controller.ts new file mode 100644 index 0000000..0b95c49 --- /dev/null +++ b/src/modules/admin/questions/admin-questions.controller.ts @@ -0,0 +1,59 @@ +import { Role } from '@/common/enums/roles.enum'; +import { Roles } from '@/modules/auth/decorators/roles.decorator'; +import { CreateQuestionDTO } from '@/modules/questions/dtos/create-question.dto'; +import { FindQuestionsDTO } from '@/modules/questions/dtos/find-questions.dto'; +import { UpdateQuestionDTO } from '@/modules/questions/dtos/update-question.dto'; +import { QuestionsService } from '@/modules/questions/providers/questions.service'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; + +@Controller('admin/questions') +@Roles(Role.SUPERUSER, Role.ADMIN) +export class AdminQuestionsController { + constructor( + /** + * Inject Questions Service Provider + */ + private readonly questionsService: QuestionsService, + ) {} + + @Post() + public async createQuestion(@Body() createQuestionDto: CreateQuestionDTO) { + return await this.questionsService.createOne(createQuestionDto); + } + + @Get() + public async getAllQuestions(@Query() query: FindQuestionsDTO) { + return await this.questionsService.findAllForAdmin(query); + } + + @Get(':id') + public async getQuestionById(@Param('id', ParseIntPipe) id: number) { + return await this.questionsService.findOneForAdmin(id); + } + + @Patch(':id') + public async updateQuestionById( + @Param('id', ParseIntPipe) id: number, + @Body() updateQuestionDto: UpdateQuestionDTO, + ) { + return await this.questionsService.updateOne(id, updateQuestionDto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + public async deleteQuestionById(@Param('id', ParseIntPipe) id: number) { + return await this.questionsService.deleteOne(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 new file mode 100644 index 0000000..872ed5a --- /dev/null +++ b/src/modules/admin/questions/http/admin-questions.delete.endpoints.http @@ -0,0 +1,2 @@ +DELETE http://localhost:3000/admin/questions/11 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxNDQ2Njg2LCJleHAiOjE3ODE0NTAyODYsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.3L6jYtNwlcLr9A9lJo6pOiTIi2rHKIqSXWphH4mgbSU diff --git a/src/modules/admin/questions/http/admin-questions.get.endpoints.http b/src/modules/admin/questions/http/admin-questions.get.endpoints.http new file mode 100644 index 0000000..9872f64 --- /dev/null +++ b/src/modules/admin/questions/http/admin-questions.get.endpoints.http @@ -0,0 +1,5 @@ +GET http://localhost:3000/admin/questions +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxMTkxMjMxLCJleHAiOjE3ODExOTQ4MzEsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.6WsJHzWWQSCEzPklyHePOmXQhfH4pfIIUZA3c5Z3yFI + +GET http://localhost:3000/admin/questions/1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxMTkxMjMxLCJleHAiOjE3ODExOTQ4MzEsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.6WsJHzWWQSCEzPklyHePOmXQhfH4pfIIUZA3c5Z3yFI diff --git a/src/modules/admin/questions/http/admin-questions.patch.endpoints.http b/src/modules/admin/questions/http/admin-questions.patch.endpoints.http new file mode 100644 index 0000000..7715cd4 --- /dev/null +++ b/src/modules/admin/questions/http/admin-questions.patch.endpoints.http @@ -0,0 +1,9 @@ +PATCH http://localhost:3000/admin/questions/1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxNDIyMjkxLCJleHAiOjE3ODE0MjU4OTEsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.djDjd0v7UwfITA4Bh-PC5G830o9wu2CgRKRenwuJJ4E +Content-Type: application/json + +{ + "metadata": { + "allowOther": true + } +} diff --git a/src/modules/admin/questions/http/admin-questions.post.endpoints.http b/src/modules/admin/questions/http/admin-questions.post.endpoints.http new file mode 100644 index 0000000..754beb0 --- /dev/null +++ b/src/modules/admin/questions/http/admin-questions.post.endpoints.http @@ -0,0 +1,12 @@ +POST http://localhost:3000/admin/questions +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxMTYyMTUxLCJleHAiOjE3ODExNjU3NTEsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.Z4oBnTrD-wTTlcThCSGslRco_HJhnQJpg-reGubTUec +Content-Type: application/json + +{ + "test": 4, + "content": "سوال پنجم", + "order": 4, + "required": true, + "type": "numeric", + "metadata": { "min": 1, "max": 100 } +} \ No newline at end of file diff --git a/src/modules/auth/http/verify-otp.post.endpoints.http b/src/modules/auth/http/verify-otp.post.endpoints.http index d08a021..00db320 100644 --- a/src/modules/auth/http/verify-otp.post.endpoints.http +++ b/src/modules/auth/http/verify-otp.post.endpoints.http @@ -2,6 +2,6 @@ POST http://localhost:3000/auth/verify-otp Content-Type: application/json { - "phone": "09121111111", - "otp": "69210" + "phone": "09365858721", + "otp": "40023" } \ No newline at end of file diff --git a/src/modules/questions/constants/index.ts b/src/modules/questions/constants/index.ts new file mode 100644 index 0000000..26aab3b --- /dev/null +++ b/src/modules/questions/constants/index.ts @@ -0,0 +1 @@ +export const UNIQUE_TEST_ORDER_IDX = 'UNQ_TEST_ORDER_IDX'; diff --git a/src/modules/questions/dtos/create-question.dto.ts b/src/modules/questions/dtos/create-question.dto.ts new file mode 100644 index 0000000..856f9f8 --- /dev/null +++ b/src/modules/questions/dtos/create-question.dto.ts @@ -0,0 +1,60 @@ +import { + IsBoolean, + IsEnum, + IsNotEmpty, + IsOptional, + IsPositive, + IsString, + Validate, +} from 'class-validator'; +import { QuestionType } from '../enums/question-type.enum'; +import { MultiChoiceMetadataDTO } from './multi-choice-metadata.dto'; +import { NumericMetadataDTO } from './numeric-metadata.dto'; +import { QuestionMetadataValidator } from '../validators/question-metadata.validator'; +import { i18nValidationMessage as t } from 'nestjs-i18n'; + +export class CreateQuestionDTO { + @IsPositive({ + message: t('validation.wrongField', { + field: '$t(questions.fields.test)', + }), + }) + @IsNotEmpty({ + message: t('validation.requiredField', { + field: '$t(questions.fields.test)', + }), + }) + test!: number; + + @IsEnum(QuestionType) + @IsOptional() + type?: QuestionType; + + @IsString() + @IsNotEmpty({ + message: t('validation.requiredField', { + field: '$t(questions.fields.content)', + }), + }) + content!: string; + + @IsPositive({ + message: t('validation.wrongField', { + field: '$t(questions.fields.order)', + }), + }) + @IsNotEmpty({ + message: t('validation.requiredField', { + field: '$t(questions.fields.order)', + }), + }) + order!: number; + + @IsBoolean() + @IsOptional() + required?: boolean; + + @Validate(QuestionMetadataValidator) + @IsOptional() + metadata?: MultiChoiceMetadataDTO | NumericMetadataDTO; +} diff --git a/src/modules/questions/dtos/find-questions.dto.ts b/src/modules/questions/dtos/find-questions.dto.ts new file mode 100644 index 0000000..a5d20be --- /dev/null +++ b/src/modules/questions/dtos/find-questions.dto.ts @@ -0,0 +1,41 @@ +import { PartialType } from '@nestjs/swagger'; +import { QuestionType } from '../enums/question-type.enum'; +import { PaginationQueryDTO } from '@/common/dtos/pagination-query.dto'; +import { + IsBoolean, + IsEnum, + IsNumber, + IsOptional, + IsString, +} from 'class-validator'; +import { QuestionSortField } from '../enums/question-sort-field.enum'; + +export class FindQuestionsDTO extends PartialType(PaginationQueryDTO) { + @IsNumber() + @IsOptional() + id?: number; + + @IsNumber() + @IsOptional() + test?: number; + + @IsEnum(QuestionType) + @IsOptional() + type?: QuestionType; + + @IsString() + @IsOptional() + content?: string; + + @IsNumber() + @IsOptional() + questionOrder?: number; + + @IsBoolean() + @IsOptional() + required?: boolean; + + @IsEnum(QuestionSortField) + @IsOptional() + sortBy?: QuestionSortField; +} diff --git a/src/modules/questions/dtos/multi-choice-metadata.dto.ts b/src/modules/questions/dtos/multi-choice-metadata.dto.ts new file mode 100644 index 0000000..43bd1a1 --- /dev/null +++ b/src/modules/questions/dtos/multi-choice-metadata.dto.ts @@ -0,0 +1,7 @@ +import { IsBoolean, IsOptional } from 'class-validator'; + +export class MultiChoiceMetadataDTO { + @IsBoolean() + @IsOptional() + allowOther?: boolean; +} diff --git a/src/modules/questions/dtos/numeric-metadata.dto.ts b/src/modules/questions/dtos/numeric-metadata.dto.ts new file mode 100644 index 0000000..7186bc7 --- /dev/null +++ b/src/modules/questions/dtos/numeric-metadata.dto.ts @@ -0,0 +1,13 @@ +import { IsNumber, IsOptional } from 'class-validator'; + +export class NumericMetadataDTO { + @IsNumber() + min!: number; + + @IsNumber() + max!: number; + + @IsNumber() + @IsOptional() + step?: number; +} diff --git a/src/modules/questions/dtos/update-question.dto.ts b/src/modules/questions/dtos/update-question.dto.ts new file mode 100644 index 0000000..c4aa992 --- /dev/null +++ b/src/modules/questions/dtos/update-question.dto.ts @@ -0,0 +1,44 @@ +import { + IsBoolean, + IsEnum, + IsOptional, + IsPositive, + IsString, +} from 'class-validator'; +import { i18nValidationMessage as t } from 'nestjs-i18n'; +import { QuestionType } from '../enums/question-type.enum'; +import { MultiChoiceMetadataDTO } from './multi-choice-metadata.dto'; +import { NumericMetadataDTO } from './numeric-metadata.dto'; + +export class UpdateQuestionDTO { + @IsPositive({ + message: t('validation.wrongField', { + field: '$t(questions.fields.test)', + }), + }) + @IsOptional() + test?: number; + + @IsEnum(QuestionType) + @IsOptional() + type?: QuestionType; + + @IsString() + @IsOptional() + content?: string; + + @IsPositive({ + message: t('validation.wrongField', { + field: '$t(questions.fields.order)', + }), + }) + @IsOptional() + order?: number; + + @IsBoolean() + @IsOptional() + required?: boolean; + + @IsOptional() + metadata?: MultiChoiceMetadataDTO | NumericMetadataDTO; +} diff --git a/src/modules/questions/entities/question.entity.ts b/src/modules/questions/entities/question.entity.ts index 1123aac..603809a 100644 --- a/src/modules/questions/entities/question.entity.ts +++ b/src/modules/questions/entities/question.entity.ts @@ -3,6 +3,7 @@ import { Column, CreateDateColumn, Entity, + Index, ManyToOne, OneToMany, PrimaryGeneratedColumn, @@ -10,8 +11,10 @@ import { 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'; @Entity() +@Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true }) export class Question { @PrimaryGeneratedColumn() id!: number; @@ -28,7 +31,7 @@ export class Question { }) type!: QuestionType; - @Column({ type: 'text' }) + @Column({ type: 'text', nullable: false }) content!: string; @Column({ type: 'int', nullable: false }) diff --git a/src/modules/questions/enums/question-sort-field.enum.ts b/src/modules/questions/enums/question-sort-field.enum.ts new file mode 100644 index 0000000..8915be6 --- /dev/null +++ b/src/modules/questions/enums/question-sort-field.enum.ts @@ -0,0 +1,8 @@ +export enum QuestionSortField { + ID = 'id', + TEST = 'test', + TYPE = 'type', + CONTENT = 'content', + ORDER = 'order', + CREATED_AT = 'createdAt', +} diff --git a/src/modules/questions/helpers/filter-questions.helper.ts b/src/modules/questions/helpers/filter-questions.helper.ts new file mode 100644 index 0000000..04f08ff --- /dev/null +++ b/src/modules/questions/helpers/filter-questions.helper.ts @@ -0,0 +1,29 @@ +import { FindOptionsWhere, ILike } from 'typeorm'; +import { FindQuestionsDTO } from '../dtos/find-questions.dto'; +import { Question } from '../entities/question.entity'; + +export const filterQuestions = (query: FindQuestionsDTO) => { + const where: FindOptionsWhere = {}; + + if (query.id) { + where.id = query.id; + } + + if (query.content) { + where.content = ILike(`%${query.content}%`); + } + + if (query.questionOrder !== undefined) { + where.order = query.questionOrder; + } + + if (query.required !== undefined) { + where.required = query.required; + } + + if (query.type !== undefined) { + where.type = query.type; + } + + return where; +}; diff --git a/src/modules/questions/helpers/validate-metadata.helper.ts b/src/modules/questions/helpers/validate-metadata.helper.ts new file mode 100644 index 0000000..d374aed --- /dev/null +++ b/src/modules/questions/helpers/validate-metadata.helper.ts @@ -0,0 +1,34 @@ +import { plainToInstance } from 'class-transformer'; +import { QuestionType } from '../enums/question-type.enum'; +import { QuestionMetadata } from '../types/question-metadata.type'; +import { NumericMetadataDTO } from '../dtos/numeric-metadata.dto'; +import { MultiChoiceMetadataDTO } from '../dtos/multi-choice-metadata.dto'; +import { validate } from 'class-validator'; +import { ValidationError } from '@nestjs/common'; + +export const validateMetadata = async ( + type: QuestionType, + metadata: QuestionMetadata, +): Promise => { + if (!metadata) { + return []; + } + + let validateDto: object; + + switch (type) { + case QuestionType.NUMERIC: + validateDto = plainToInstance(NumericMetadataDTO, metadata); + break; + + default: + case QuestionType.MULTI_CHOICE: + validateDto = plainToInstance(MultiChoiceMetadataDTO, metadata); + break; + } + + return await validate(validateDto, { + whitelist: true, + forbidNonWhitelisted: true, + }); +}; diff --git a/src/modules/questions/providers/questions.service.ts b/src/modules/questions/providers/questions.service.ts new file mode 100644 index 0000000..4506f69 --- /dev/null +++ b/src/modules/questions/providers/questions.service.ts @@ -0,0 +1,194 @@ +import { + ConflictException, + HttpStatus, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { QueryFailedError, Repository } from 'typeorm'; +import { Question } from '../entities/question.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { CreateQuestionDTO } from '../dtos/create-question.dto'; +import { TestsService } from '@/modules/tests/providers/tests.service'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { AppResponse, PaginatedResponse } from '@/common/responses'; +import { I18nService } from 'nestjs-i18n'; +import { UNIQUE_TEST_ORDER_IDX } from '../constants'; +import { FindQuestionsDTO } from '../dtos/find-questions.dto'; +import { filterQuestions } from '../helpers/filter-questions.helper'; +import Utils from '@/common/utils'; +import { Test } from '@/modules/tests/entities/test.entity'; +import { UpdateQuestionDTO } from '../dtos/update-question.dto'; +import { validateMetadata } from '../helpers/validate-metadata.helper'; +import { AppException } from '@/common/exceptions/app.exception'; + +@Injectable() +export class QuestionsService { + constructor( + /** + * Inject Questions Repository + */ + @InjectRepository(Question) + private readonly questionsRepository: Repository, + + /** + * Inject Tests Service + */ + private readonly testsService: TestsService, + + /** + * Inject I18n Service + */ + private readonly i18nService: I18nService, + ) {} + + public async createOne(createQuestionDto: CreateQuestionDTO) { + const test = await this.testsService.adminGetOne(createQuestionDto.test); + + if (!test) { + throw new NotFoundException('tests.errors.testNotFound'); + } + + const newQuestion = this.questionsRepository.create({ + ...createQuestionDto, + test, + }); + + try { + await this.questionsRepository.save(newQuestion); + return new AppResponse( + newQuestion, + this.i18nService.translate('questions.messages.createSuccess'), + ); + } catch (err) { + if ( + err instanceof QueryFailedError && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (err as any).constraint === UNIQUE_TEST_ORDER_IDX + ) { + throw new ConflictException('questions.errors.uniqueOrderConstraint'); + } + + throw new AppTimeoutException(err); + } + } + + public async findAllForAdmin(query: FindQuestionsDTO) { + const filters = filterQuestions(query); + const { limit, page, skip, take } = + Utils.PaginationUtils.getPaginationOptions(query); + + let selectedTest: Test | null = null; + + if (query.test) { + try { + selectedTest = await this.testsService.adminGetOne(query.test); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + try { + const [questions, total] = await this.questionsRepository.findAndCount({ + where: { + ...filters, + ...(selectedTest && { + test: selectedTest, + }), + }, + loadRelationIds: true, + skip, + take, + order: query.sortBy + ? { + [query.sortBy]: query.order ?? 'ASC', + } + : { createdAt: 'DESC' }, + }); + + return new PaginatedResponse(questions, { + page, + limit, + total, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + public async findOneForAdmin(id: number) { + try { + return await this.questionsRepository.findOne({ + where: { id }, + relations: { test: true }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + public async updateOne(id: number, updateQuestionDto: UpdateQuestionDTO) { + const question = await this.questionsRepository.findOneBy({ id }); + + if (!question) { + throw new NotFoundException('questions.errors.questionNotFound'); + } + + const type = + updateQuestionDto.type !== undefined + ? updateQuestionDto.type + : question.type; + + const metadata = + updateQuestionDto.metadata !== undefined + ? updateQuestionDto.metadata + : question.metadata; + + const errors = await validateMetadata(type, metadata); + + if (errors && errors.length > 0) { + throw new AppException( + 'validation.wrongFieldFormat', + HttpStatus.BAD_REQUEST, + { field: '$t(questions.fields.metadata)' }, + ); + } + + Object.assign(question, updateQuestionDto); + + console.log(question); + + try { + return await this.questionsRepository.save(question); + } catch (err) { + if ( + err instanceof QueryFailedError && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (err as any).constraint === UNIQUE_TEST_ORDER_IDX + ) { + throw new ConflictException('questions.errors.uniqueOrderConstraint'); + } + + throw new AppTimeoutException(err); + } + } + + public async deleteOne(id: number) { + let question: Question | null = null; + + try { + question = await this.questionsRepository.findOneBy({ id }); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!question) + throw new NotFoundException('questions.errors.questionNotFound'); + + try { + await this.questionsRepository.delete(question.id); + } catch (err) { + console.log(err); + throw new AppTimeoutException(err); + } + } +} diff --git a/src/modules/questions/questions.controller.ts b/src/modules/questions/questions.controller.ts new file mode 100644 index 0000000..8ba398f --- /dev/null +++ b/src/modules/questions/questions.controller.ts @@ -0,0 +1,4 @@ +import { Controller } from '@nestjs/common'; + +@Controller('questions') +export class QuestionsController {} diff --git a/src/modules/questions/questions.module.ts b/src/modules/questions/questions.module.ts index 4cdf16b..92befc8 100644 --- a/src/modules/questions/questions.module.ts +++ b/src/modules/questions/questions.module.ts @@ -2,8 +2,14 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Question } from './entities/question.entity'; import { Choice } from './entities/choice.entity'; +import { QuestionsController } from './questions.controller'; +import { TestsModule } from '../tests/tests.module'; +import { QuestionsService } from './providers/questions.service'; @Module({ - imports: [TypeOrmModule.forFeature([Question, Choice])], + imports: [TypeOrmModule.forFeature([Question, Choice]), TestsModule], + controllers: [QuestionsController], + providers: [QuestionsService], + exports: [QuestionsService], }) export class QuestionsModule {} diff --git a/src/modules/questions/validators/question-metadata.validator.ts b/src/modules/questions/validators/question-metadata.validator.ts new file mode 100644 index 0000000..352899d --- /dev/null +++ b/src/modules/questions/validators/question-metadata.validator.ts @@ -0,0 +1,39 @@ +// question-metadata.validator.ts +import { + ValidatorConstraint, + ValidatorConstraintInterface, + ValidationArguments, +} from 'class-validator'; +import { QuestionType } from '../enums/question-type.enum'; +import { I18nContext } from 'nestjs-i18n'; +import { validateMetadata } from '../helpers/validate-metadata.helper'; +import { QuestionMetadata } from '../types/question-metadata.type'; + +@ValidatorConstraint({ name: 'QuestionMetadata', async: false }) +export class QuestionMetadataValidator implements ValidatorConstraintInterface { + async validate(value: any, args: ValidationArguments): Promise { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const obj = args.object as any; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { type } = obj; + + const errors = await validateMetadata( + type as QuestionType, + value as QuestionMetadata, + ); + + return errors.length === 0; + } + + defaultMessage(): string { + const i18n = I18nContext.current(); + + const field = i18n?.t('questions.fields.metadata') ?? 'Metadata'; + + return ( + i18n?.t('validation.wrongFieldFormat', { + args: { field }, + }) ?? 'Metadata field format is wrong' + ); + } +} diff --git a/src/modules/scoring/entities/result-range.entity.ts b/src/modules/scoring/entities/result-range.entity.ts index 0e3f43b..2fedae1 100644 --- a/src/modules/scoring/entities/result-range.entity.ts +++ b/src/modules/scoring/entities/result-range.entity.ts @@ -4,35 +4,35 @@ import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class ResultRange { @PrimaryGeneratedColumn() - id: number; + id!: number; @ManyToOne(() => Test, (test) => test.resultRanges, { onDelete: 'CASCADE', }) - test: Test; + test!: Test; @Column({ type: 'int', nullable: false, }) - minScore: number; + minScore!: number; @Column({ type: 'int', nullable: false, }) - maxScore: number; + maxScore!: number; @Column({ type: 'varchar', length: 255, nullable: false, }) - title: string; + title!: string; @Column({ type: 'text', nullable: false, }) - description: string; + description!: string; } diff --git a/src/modules/tests/helpers/filter-tests.helper.ts b/src/modules/tests/helpers/filter-tests.helper.ts index 4a29b83..c005782 100644 --- a/src/modules/tests/helpers/filter-tests.helper.ts +++ b/src/modules/tests/helpers/filter-tests.helper.ts @@ -1,8 +1,9 @@ import { FindOptionsWhere, ILike } from 'typeorm'; import { FindTestsDTO } from '../dtos/find-tests.dto'; +import { Test } from '../entities/test.entity'; export const filterTests = (query: FindTestsDTO) => { - const where: FindOptionsWhere = {}; + const where: FindOptionsWhere = {}; if (query.id) { where.id = query.id; diff --git a/src/modules/tests/providers/tests-command.provider.ts b/src/modules/tests/providers/tests-command.provider.ts index 009ed14..c895529 100644 --- a/src/modules/tests/providers/tests-command.provider.ts +++ b/src/modules/tests/providers/tests-command.provider.ts @@ -70,9 +70,7 @@ export class TestsCommandProvider { (accessType === AccessType.PAID && !price) || (accessType !== AccessType.PAID && price) ) { - throw new BadRequestException( - this.i18nService.translate('tests.errors.priceAccessConstraint'), - ); + throw new BadRequestException('tests.errors.priceAccessConstraint'); } Object.assign(test, updateTestDto); diff --git a/src/modules/tests/validators/price-access-type-constraint.validator.ts b/src/modules/tests/validators/price-access-type-constraint.validator.ts index 69a9812..8c9850c 100644 --- a/src/modules/tests/validators/price-access-type-constraint.validator.ts +++ b/src/modules/tests/validators/price-access-type-constraint.validator.ts @@ -12,8 +12,6 @@ export class PriceAccessTypeConstraint implements ValidatorConstraintInterface { _: any, validationArguments?: ValidationArguments, ): Promise | boolean { - console.log('This is happening'); - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const obj = validationArguments?.object as any; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index cb7e370..606db6e 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -11,7 +11,7 @@ import { @Entity() export class User { @PrimaryGeneratedColumn() - id: number; + id!: number; @Column({ type: 'varchar', @@ -34,7 +34,7 @@ export class User { nullable: false, unique: true, }) - phone: string; + phone!: string; @Column({ type: 'varchar', @@ -48,11 +48,11 @@ export class User { enum: Role, default: Role.USER, }) - role: Role; + role!: Role; @CreateDateColumn() - createdAt: Date; + createdAt!: Date; @UpdateDateColumn() - updatedAt: Date; + updatedAt!: Date; }