diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json index 62bbc6e..cf69587 100644 --- a/src/i18n/en/common.json +++ b/src/i18n/en/common.json @@ -2,6 +2,12 @@ "errors": { "internalServerError": "Internal Server Error", "tooManyRequests": "Too many requests! Try again after {minutes} min.", - "requestTimeout": "Unable to process your request at the moment." + "requestTimeout": "Unable to process your request at the moment.", + "entityNotFound": "{entity} not found." + }, + "messages": { + "createSuccess": "{entity} created successfully.", + "updateSuccess": "Changes were made successfully.", + "deleteSuccess": "{entity} deleted successfully." } } diff --git a/src/i18n/en/questions.json b/src/i18n/en/questions.json index 4ad3ac1..8e12b65 100644 --- a/src/i18n/en/questions.json +++ b/src/i18n/en/questions.json @@ -5,13 +5,17 @@ "content": "Content", "order": "Order", "required": "Required", - "metadata": "Metadata" + "metadata": "Metadata", + "question": "Question", + "text": "Text", + "value": "Value", + "choice": "Choice" }, "errors": { - "uniqueOrderConstraint": "Order value should be unique.", - "questionNotFound": "Question not found." + "uniqueOrderConstraint": "Order value should be unique." }, "messages": { - "createSuccess": "Question Created Successfully." + "createSuccess": "Question Created Successfully.", + "choiceCreateSuccess": "Choice Created Successfully." } } diff --git a/src/i18n/en/tests.json b/src/i18n/en/tests.json index 2802d87..2ae7136 100644 --- a/src/i18n/en/tests.json +++ b/src/i18n/en/tests.json @@ -4,11 +4,11 @@ "description": "Description", "accessType": "Access Type", "price": "Price", - "isActive": "Is Active" + "isActive": "Is Active", + "test": "Test" }, "errors": { "intPrice": "Price should be an integer.", - "testNotFound": "Test not found.", "priceAccessConstraint": "Test's price depends on access type of 'Paid'." }, "messages": { diff --git a/src/i18n/fa/common.json b/src/i18n/fa/common.json index ac16648..0a2074f 100644 --- a/src/i18n/fa/common.json +++ b/src/i18n/fa/common.json @@ -2,6 +2,12 @@ "errors": { "internalServerError": "خطایی رخ داد", "tooManyRequests": "تعداد درخواست ها بیشتر از حد مجاز است! لطفا بعد از {minutes} دقیقه دوباره امتحان کنید.", - "requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد." + "requestTimeout": "امکان پردازش درخواست شما در این لحظه وجود ندارد.", + "entityNotFound": "{entity} مورد نظر یافت نشد." + }, + "messages": { + "createSuccess": "{entity} با موفقیت ایجاد شد.", + "updateSuccess": "تغییرات با موفقیت اعمال شد.", + "deleteSuccess": "{entity} با موفقیت حذف شد." } } diff --git a/src/i18n/fa/questions.json b/src/i18n/fa/questions.json index 33df268..81b86be 100644 --- a/src/i18n/fa/questions.json +++ b/src/i18n/fa/questions.json @@ -5,13 +5,17 @@ "content": "محتوا", "order": "اولویت", "required": "اجباری", - "metadata": "تنظیمات" + "metadata": "تنظیمات", + "question": "سوال", + "text": "متن", + "value": "مقدار", + "choice": "گزینه" }, "errors": { - "uniqueOrderConstraint": "مقدار اولویت تکراری است.", - "questionNotFound": "Question not found." + "uniqueOrderConstraint": "مقدار اولویت تکراری است." }, "messages": { - "createSuccess": "سوال با موفقیت ایجاد شد." + "createSuccess": "سوال با موفقیت ایجاد شد.", + "choiceCreateSuccess": "گزینه با موفقیت ایجاد شد." } } diff --git a/src/i18n/fa/tests.json b/src/i18n/fa/tests.json index 705933e..9d57bbf 100644 --- a/src/i18n/fa/tests.json +++ b/src/i18n/fa/tests.json @@ -4,11 +4,11 @@ "description": "توضیحات", "accessType": "نوع دسترسی", "price": "قیمت", - "isActive": "فعال بودن" + "isActive": "فعال بودن", + "test": "تست" }, "errors": { "intPrice": "قیمت باید عدد صحیح باشد.", - "testNotFound": "تست مورد نظر یافت نشد.", "priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است." }, "messages": { diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index a025147..e064307 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -8,6 +8,7 @@ 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'; +import { AdminChoicesController } from './choices/admin-choices.controller'; @Module({ imports: [ @@ -21,6 +22,7 @@ import { AdminQuestionsController } from './questions/admin-questions.controller AdminTestsController, AdminAuthController, AdminQuestionsController, + AdminChoicesController, ], providers: [AdminAuthService], }) diff --git a/src/modules/admin/choices/admin-choices.controller.ts b/src/modules/admin/choices/admin-choices.controller.ts new file mode 100644 index 0000000..9d8e15c --- /dev/null +++ b/src/modules/admin/choices/admin-choices.controller.ts @@ -0,0 +1,59 @@ +import { Role } from '@/common/enums/roles.enum'; +import { Roles } from '@/modules/auth/decorators/roles.decorator'; +import { CreateChoiceDTO } from '@/modules/questions/dtos/create-choice.dto'; +import { FindChoicesDTO } from '@/modules/questions/dtos/find-choices.dto'; +import { UpdateChoiceDTO } from '@/modules/questions/dtos/update-choice.dto'; +import { ChoicesService } from '@/modules/questions/providers/choices.service'; +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; + +@Controller('admin/choices') +@Roles(Role.SUPERUSER, Role.ADMIN) +export class AdminChoicesController { + constructor( + /** + * Inject Choices Service + */ + private readonly choicesService: ChoicesService, + ) {} + + @Post() + public async createChoice(@Body() createChoiceDto: CreateChoiceDTO) { + return await this.choicesService.createOne(createChoiceDto); + } + + @Patch(':id') + public async updateChoice( + @Param('id', ParseIntPipe) id: number, + @Body() updateChoiceDto: UpdateChoiceDTO, + ) { + return await this.choicesService.updateOne(id, updateChoiceDto); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + public async deleteChoice(@Param('id', ParseIntPipe) id: number) { + return await this.choicesService.deleteOne(id); + } + + @Get() + public async getAllChoices(@Query() query: FindChoicesDTO) { + return await this.choicesService.findAllForAdmin(query); + } + + @Get(':id') + public async getChoiceById(@Param('id', ParseIntPipe) id: number) { + return await this.choicesService.findOneForAdmin(id); + } +} diff --git a/src/modules/admin/choices/http/admin-choices-get-endpoints.http b/src/modules/admin/choices/http/admin-choices-get-endpoints.http new file mode 100644 index 0000000..70c91e7 --- /dev/null +++ b/src/modules/admin/choices/http/admin-choices-get-endpoints.http @@ -0,0 +1,5 @@ +GET http://localhost:3000/admin/choices/ +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxNjgxNzAzLCJleHAiOjE3ODE2ODUzMDMsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.WD14d4CqEiM1EkNFf9ky1B-i32RmoVGbYYXaYJs4UC0 + +GET http://localhost:3000/admin/choices/1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxNjgxNzAzLCJleHAiOjE3ODE2ODUzMDMsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.WD14d4CqEiM1EkNFf9ky1B-i32RmoVGbYYXaYJs4UC0 diff --git a/src/modules/admin/choices/http/admin-choices.delete.endpoints.http b/src/modules/admin/choices/http/admin-choices.delete.endpoints.http new file mode 100644 index 0000000..13f4cf3 --- /dev/null +++ b/src/modules/admin/choices/http/admin-choices.delete.endpoints.http @@ -0,0 +1,2 @@ +DELETE http://localhost:3000/admin/choices/6 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxNTg0MjY1LCJleHAiOjE3ODE1ODc4NjUsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.lV1U9uXQTAJmCamZfCDcDpITfIZs4QlVd9Fcjsr0BrA diff --git a/src/modules/admin/choices/http/admin-choices.patch.endpoints.http b/src/modules/admin/choices/http/admin-choices.patch.endpoints.http new file mode 100644 index 0000000..c66f865 --- /dev/null +++ b/src/modules/admin/choices/http/admin-choices.patch.endpoints.http @@ -0,0 +1,7 @@ +PATCH http://localhost:3000/admin/choices/1 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxNTMwNTI1LCJleHAiOjE3ODE1MzQxMjUsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.C_Ol-dkX7mouofKqbc-VxtP4qF6WiCPmlpKXuq85DiQ +Content-Type: application/json + +{ + "question": 4 +} diff --git a/src/modules/admin/choices/http/admin-choices.post.endpoints.http b/src/modules/admin/choices/http/admin-choices.post.endpoints.http new file mode 100644 index 0000000..5929cfe --- /dev/null +++ b/src/modules/admin/choices/http/admin-choices.post.endpoints.http @@ -0,0 +1,10 @@ +POST http://localhost:3000/admin/choices +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxNTg0MjY1LCJleHAiOjE3ODE1ODc4NjUsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.lV1U9uXQTAJmCamZfCDcDpITfIZs4QlVd9Fcjsr0BrA +Content-Type: application/json + +{ + "question": 4, + "text": "Option 2", + "value": 2, + "order": 2 +} \ No newline at end of file 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 7715cd4..f719c28 100644 --- a/src/modules/admin/questions/http/admin-questions.patch.endpoints.http +++ b/src/modules/admin/questions/http/admin-questions.patch.endpoints.http @@ -1,9 +1,7 @@ -PATCH http://localhost:3000/admin/questions/1 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxNDIyMjkxLCJleHAiOjE3ODE0MjU4OTEsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.djDjd0v7UwfITA4Bh-PC5G830o9wu2CgRKRenwuJJ4E +PATCH http://localhost:3000/admin/questions/4 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxNTg0MjY1LCJleHAiOjE3ODE1ODc4NjUsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.lV1U9uXQTAJmCamZfCDcDpITfIZs4QlVd9Fcjsr0BrA Content-Type: application/json { - "metadata": { - "allowOther": true - } + "test": 4 } diff --git a/src/modules/admin/tests/http/admin-tests.delete.endpoints.http b/src/modules/admin/tests/http/admin-tests.delete.endpoints.http index 1c7aaa8..203286c 100644 --- a/src/modules/admin/tests/http/admin-tests.delete.endpoints.http +++ b/src/modules/admin/tests/http/admin-tests.delete.endpoints.http @@ -1,2 +1,2 @@ -DELETE http://localhost:3000/admin/tests/1 -Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgwNzQ5MTcwLCJleHAiOjE3ODA3NTI3NzAsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.apRhtVZgFMFcyutZGO7uzckscch9Jw42s0RCSZZv4K0 +DELETE http://localhost:3000/admin/tests/100 +Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxNTI2OTMyLCJleHAiOjE3ODE1MzA1MzIsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.RVtpegax_yXI1PL91WvNWiIDDLTaJcPUUWU6Be9Hg1k diff --git a/src/modules/questions/constants/index.ts b/src/modules/questions/constants/index.ts index 26aab3b..17c94b4 100644 --- a/src/modules/questions/constants/index.ts +++ b/src/modules/questions/constants/index.ts @@ -1 +1,2 @@ export const UNIQUE_TEST_ORDER_IDX = 'UNQ_TEST_ORDER_IDX'; +export const UNIQUE_QUESTION_ORDER_IDX = 'UNQ_QUESTION_ORDER_IDX'; diff --git a/src/modules/questions/dtos/create-choice.dto.ts b/src/modules/questions/dtos/create-choice.dto.ts new file mode 100644 index 0000000..c424c5b --- /dev/null +++ b/src/modules/questions/dtos/create-choice.dto.ts @@ -0,0 +1,45 @@ +import { IsNotEmpty, IsNumber, IsPositive, MaxLength } from 'class-validator'; +import { i18nValidationMessage as t } from 'nestjs-i18n'; + +export class CreateChoiceDTO { + @IsPositive({ + message: t('validation.wrongField', { + field: '$t(questions.fields.question)', + }), + }) + @IsNotEmpty({ + message: t('validation.requiredField', { + field: '$t(questions.fields.question)', + }), + }) + question!: number; + + @IsNotEmpty({ + message: t('validation.requiredField', { + field: '$t(questions.fields.text)', + }), + }) + @MaxLength(255, { + message: t('validation.maxLengthField', { + field: '$t(users.fields.text)', + max: 255, + }), + }) + text!: string; + + @IsNumber() + @IsNotEmpty({ + message: t('validation.requiredField', { + field: '$t(questions.fields.value)', + }), + }) + value!: number; + + @IsNumber() + @IsNotEmpty({ + message: t('validation.requiredField', { + field: '$t(questions.fields.order)', + }), + }) + order!: number; +} diff --git a/src/modules/questions/dtos/find-choices.dto.ts b/src/modules/questions/dtos/find-choices.dto.ts new file mode 100644 index 0000000..ac6cda8 --- /dev/null +++ b/src/modules/questions/dtos/find-choices.dto.ts @@ -0,0 +1,30 @@ +import { PaginationQueryDTO } from '@/common/dtos/pagination-query.dto'; +import { PartialType } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; +import { ChoiceSortField } from '../enums/choice-sort-field.enum'; + +export class FindChoicesDTO extends PartialType(PaginationQueryDTO) { + @IsNumber() + @IsOptional() + id?: number; + + @IsNumber() + @IsOptional() + question?: number; + + @IsString() + @IsOptional() + text?: string; + + @IsNumber() + @IsOptional() + value?: number; + + @IsNumber() + @IsOptional() + choiceOrder?: number; + + @IsEnum(ChoiceSortField) + @IsOptional() + sortBy?: ChoiceSortField; +} diff --git a/src/modules/questions/dtos/update-choice.dto.ts b/src/modules/questions/dtos/update-choice.dto.ts new file mode 100644 index 0000000..9bd8695 --- /dev/null +++ b/src/modules/questions/dtos/update-choice.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateChoiceDTO } from './create-choice.dto'; + +export class UpdateChoiceDTO extends PartialType(CreateChoiceDTO) {} diff --git a/src/modules/questions/entities/choice.entity.ts b/src/modules/questions/entities/choice.entity.ts index 34075ce..77ab5d4 100644 --- a/src/modules/questions/entities/choice.entity.ts +++ b/src/modules/questions/entities/choice.entity.ts @@ -1,7 +1,15 @@ -import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { + Column, + Entity, + Index, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; import { Question } from './question.entity'; +import { UNIQUE_QUESTION_ORDER_IDX } from '../constants'; @Entity() +@Index(UNIQUE_QUESTION_ORDER_IDX, ['question', 'order'], { unique: true }) export class Choice { @PrimaryGeneratedColumn() id!: number; diff --git a/src/modules/questions/enums/choice-sort-field.enum.ts b/src/modules/questions/enums/choice-sort-field.enum.ts new file mode 100644 index 0000000..b00c3d4 --- /dev/null +++ b/src/modules/questions/enums/choice-sort-field.enum.ts @@ -0,0 +1,7 @@ +export enum ChoiceSortField { + ID = 'id', + QUESTION = 'question', + TEXT = 'text', + ORDER = 'order', + VALUE = 'value', +} diff --git a/src/modules/questions/helpers/filter-choices.helper.ts b/src/modules/questions/helpers/filter-choices.helper.ts new file mode 100644 index 0000000..1ea3a5e --- /dev/null +++ b/src/modules/questions/helpers/filter-choices.helper.ts @@ -0,0 +1,25 @@ +import { FindOptionsWhere, ILike } from 'typeorm'; +import { FindChoicesDTO } from '../dtos/find-choices.dto'; +import { Choice } from '../entities/choice.entity'; + +export const filterChoices = (query: FindChoicesDTO) => { + const where: FindOptionsWhere = {}; + + if (query.id) { + where.id = query.id; + } + + if (query.text) { + where.text = ILike(`%${query.text}%`); + } + + if (query.choiceOrder !== undefined) { + where.order = query.choiceOrder; + } + + if (query.value !== undefined) { + where.value = query.value; + } + + return where; +}; diff --git a/src/modules/questions/providers/choices-command.provider.ts b/src/modules/questions/providers/choices-command.provider.ts new file mode 100644 index 0000000..d2a2e76 --- /dev/null +++ b/src/modules/questions/providers/choices-command.provider.ts @@ -0,0 +1,149 @@ +import { ConflictException, HttpStatus, Injectable } from '@nestjs/common'; +import { CreateChoiceDTO } from '../dtos/create-choice.dto'; +import { QueryFailedError, Repository } from 'typeorm'; +import { Choice } from '../entities/choice.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Question } from '../entities/question.entity'; +import { UNIQUE_QUESTION_ORDER_IDX } from '../constants'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { AppResponse } from '@/common/responses'; +import { I18nService } from 'nestjs-i18n'; +import { AppException } from '@/common/exceptions/app.exception'; +import { UpdateChoiceDTO } from '../dtos/update-choice.dto'; + +@Injectable() +export class ChoicesCommandProvider { + constructor( + /** + * Inject Choices Repository + */ + @InjectRepository(Choice) + private readonly choicesRepository: Repository, + + /** + * Inject Questions Repository + */ + @InjectRepository(Question) + private readonly questionsRepository: Repository, + + /** + * Inject I18n Service + */ + private readonly i18nService: I18nService, + ) {} + + public async createOne(createChoiceDto: CreateChoiceDTO) { + const question = await this.questionsRepository.findOneBy({ + id: createChoiceDto.question, + }); + + if (!question) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(questions.fields.question)' }, + ); + } + + const newChoice = this.choicesRepository.create({ + ...createChoiceDto, + question, + }); + + try { + await this.choicesRepository.save(newChoice); + return new AppResponse( + newChoice, + this.i18nService.translate('common.messages.createSuccess', { + args: { entity: '$t(questions.fields.choice)' }, + }), + ); + } catch (err) { + if ( + err instanceof QueryFailedError && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (err as any).constraint === UNIQUE_QUESTION_ORDER_IDX + ) { + throw new ConflictException('questions.errors.uniqueOrderConstraint'); + } + + throw new AppTimeoutException(err); + } + } + + public async updateOne(id: number, updateChoiceDto: UpdateChoiceDTO) { + const choice = await this.choicesRepository.findOneBy({ id }); + + if (!choice) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { + entity: '$t(questions.fields.choice)', + }, + ); + } + + if (updateChoiceDto.question) { + const question = await this.questionsRepository.findOneBy({ + id: updateChoiceDto.question, + }); + + if (!question) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { + entity: '$t(questions.fields.question)', + }, + ); + } + } + + Object.assign(choice, updateChoiceDto); + + try { + const updatedChoice = await this.choicesRepository.save(choice); + return new AppResponse( + updatedChoice, + this.i18nService.translate('common.messages.updateSuccess'), + ); + } catch (err) { + if ( + err instanceof QueryFailedError && + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + (err as any).constraint === UNIQUE_QUESTION_ORDER_IDX + ) { + throw new ConflictException('questions.errors.uniqueOrderConstraint'); + } + + throw new AppTimeoutException(err); + } + } + + public async deleteOne(id: number) { + let choice: Choice | null = null; + + try { + choice = await this.choicesRepository.findOneBy({ id }); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!choice) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { + entity: '$t(questions.fields.choice)', + }, + ); + } + + try { + await this.choicesRepository.delete(choice.id); + } catch (err) { + throw new AppTimeoutException(err); + } + } +} diff --git a/src/modules/questions/providers/choices-query.provider.ts b/src/modules/questions/providers/choices-query.provider.ts new file mode 100644 index 0000000..f2fe4f1 --- /dev/null +++ b/src/modules/questions/providers/choices-query.provider.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@nestjs/common'; +import Utils from '@/common/utils'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { PaginatedResponse } from '@/common/responses'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Question } from '../entities/question.entity'; +import { Repository } from 'typeorm'; +import { Choice } from '../entities/choice.entity'; +import { FindChoicesDTO } from '../dtos/find-choices.dto'; +import { filterChoices } from '../helpers/filter-choices.helper'; + +@Injectable() +export class ChoicesQueryProvider { + constructor( + /** + * Inject Choices Repository + */ + @InjectRepository(Choice) + private readonly choicesRepository: Repository, + + /** + * Inject Questions Repository + */ + @InjectRepository(Question) + private readonly questionsRepository: Repository, + ) {} + + public async findAllForAdmin(query: FindChoicesDTO) { + const filters = filterChoices(query); + const { limit, page, skip, take } = + Utils.PaginationUtils.getPaginationOptions(query); + + let selectedQuestion: Question | null = null; + + if (query.question) { + try { + selectedQuestion = await this.questionsRepository.findOneBy({ + id: query.question, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + try { + const [choices, total] = await this.choicesRepository.findAndCount({ + where: { + ...filters, + ...(selectedQuestion && { + question: { + id: selectedQuestion.id, + }, + }), + }, + loadRelationIds: true, + skip, + take, + order: query.sortBy + ? { + [query.sortBy]: query.order ?? 'ASC', + } + : { id: 'DESC' }, + }); + + return new PaginatedResponse(choices, { + page, + limit, + total, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + public async findOneForAdmin(id: number) { + try { + return await this.choicesRepository.findOne({ + where: { id }, + relations: { question: true }, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } +} diff --git a/src/modules/questions/providers/choices.service.ts b/src/modules/questions/providers/choices.service.ts new file mode 100644 index 0000000..de89300 --- /dev/null +++ b/src/modules/questions/providers/choices.service.ts @@ -0,0 +1,41 @@ +import { Injectable } from '@nestjs/common'; +import { ChoicesCommandProvider } from './choices-command.provider'; +import { CreateChoiceDTO } from '../dtos/create-choice.dto'; +import { UpdateChoiceDTO } from '../dtos/update-choice.dto'; +import { FindChoicesDTO } from '../dtos/find-choices.dto'; +import { ChoicesQueryProvider } from './choices-query.provider'; + +@Injectable() +export class ChoicesService { + constructor( + /** + * Inject Choices Command Provider + */ + private readonly choicesCommnadProvider: ChoicesCommandProvider, + + /** + * Inject Choices Query Provider + */ + private readonly choicesQueryProvider: ChoicesQueryProvider, + ) {} + + public async createOne(createChoiceDto: CreateChoiceDTO) { + return await this.choicesCommnadProvider.createOne(createChoiceDto); + } + + public async updateOne(id: number, updateChoiceDto: UpdateChoiceDTO) { + return await this.choicesCommnadProvider.updateOne(id, updateChoiceDto); + } + + public async deleteOne(id: number) { + return await this.choicesCommnadProvider.deleteOne(id); + } + + public async findAllForAdmin(query: FindChoicesDTO) { + return await this.choicesQueryProvider.findAllForAdmin(query); + } + + public async findOneForAdmin(id: number) { + return await this.choicesQueryProvider.findOneForAdmin(id); + } +} diff --git a/src/modules/questions/providers/questions-command.provider.ts b/src/modules/questions/providers/questions-command.provider.ts new file mode 100644 index 0000000..6ce31a6 --- /dev/null +++ b/src/modules/questions/providers/questions-command.provider.ts @@ -0,0 +1,155 @@ +import { ConflictException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Question } from '../entities/question.entity'; +import { QueryFailedError, Repository } from 'typeorm'; +import { TestsService } from '@/modules/tests/providers/tests.service'; +import { I18nService } from 'nestjs-i18n'; +import { CreateQuestionDTO } from '../dtos/create-question.dto'; +import { AppResponse } from '@/common/responses'; +import { UNIQUE_TEST_ORDER_IDX } from '../constants'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { UpdateQuestionDTO } from '../dtos/update-question.dto'; +import { validateMetadata } from '../helpers/validate-metadata.helper'; +import { AppException } from '@/common/exceptions/app.exception'; + +@Injectable() +export class QuestionsCommandProvider { + 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 AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(tests.fields.test)' }, + ); + } + + const newQuestion = this.questionsRepository.create({ + ...createQuestionDto, + test, + }); + + try { + await this.questionsRepository.save(newQuestion); + return new AppResponse( + newQuestion, + this.i18nService.translate('common.messages.createSuccess', { + args: { entity: '$t(questions.fields.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 updateOne(id: number, updateQuestionDto: UpdateQuestionDTO) { + const question = await this.questionsRepository.findOneBy({ id }); + + if (!question) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(questions.fields.question)' }, + ); + } + + if (updateQuestionDto.test) { + const test = await this.testsService.adminGetOne(updateQuestionDto.test); + + if (!test) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(tests.fields.test)' }, + ); + } + } + + 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); + + 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 AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(questions.fields.question)' }, + ); + + try { + await this.questionsRepository.delete(question.id); + } catch (err) { + console.log(err); + throw new AppTimeoutException(err); + } + } +} diff --git a/src/modules/questions/providers/questions-query.provider.ts b/src/modules/questions/providers/questions-query.provider.ts new file mode 100644 index 0000000..85d32f3 --- /dev/null +++ b/src/modules/questions/providers/questions-query.provider.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { filterQuestions } from '../helpers/filter-questions.helper'; +import Utils from '@/common/utils'; +import { FindQuestionsDTO } from '../dtos/find-questions.dto'; +import { Test } from '@nestjs/testing'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { PaginatedResponse } from '@/common/responses'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Question } from '../entities/question.entity'; +import { Repository } from 'typeorm'; +import { TestsService } from '@/modules/tests/providers/tests.service'; + +@Injectable() +export class QuestionsQueryProvider { + constructor( + /** + * Inject Questions Repository + */ + @InjectRepository(Question) + private readonly questionsRepository: Repository, + + /** + * Inject Tests Service + */ + private readonly testsService: TestsService, + ) {} + + 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); + } + } +} diff --git a/src/modules/questions/providers/questions.service.ts b/src/modules/questions/providers/questions.service.ts index 4506f69..e649211 100644 --- a/src/modules/questions/providers/questions.service.ts +++ b/src/modules/questions/providers/questions.service.ts @@ -1,194 +1,41 @@ -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 { Injectable } from '@nestjs/common'; 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'; +import { QuestionsCommandProvider } from './questions-command.provider'; +import { QuestionsQueryProvider } from './questions-query.provider'; @Injectable() export class QuestionsService { constructor( /** - * Inject Questions Repository + * Inject Questions Command Provider */ - @InjectRepository(Question) - private readonly questionsRepository: Repository, + private readonly questionsCommandProvider: QuestionsCommandProvider, /** - * Inject Tests Service + * Inject Questions Query Provider */ - private readonly testsService: TestsService, - - /** - * Inject I18n Service - */ - private readonly i18nService: I18nService, + private readonly questionsQueryProvider: QuestionsQueryProvider, ) {} 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); - } + return await this.questionsCommandProvider.createOne(createQuestionDto); } 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); - } + return await this.questionsQueryProvider.findAllForAdmin(query); } public async findOneForAdmin(id: number) { - try { - return await this.questionsRepository.findOne({ - where: { id }, - relations: { test: true }, - }); - } catch (err) { - throw new AppTimeoutException(err); - } + return await this.questionsQueryProvider.findOneForAdmin(id); } 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); - } + return await this.questionsCommandProvider.updateOne(id, updateQuestionDto); } 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); - } + return await this.questionsCommandProvider.deleteOne(id); } } diff --git a/src/modules/questions/questions.module.ts b/src/modules/questions/questions.module.ts index 92befc8..9f3ee7c 100644 --- a/src/modules/questions/questions.module.ts +++ b/src/modules/questions/questions.module.ts @@ -5,11 +5,23 @@ import { Choice } from './entities/choice.entity'; import { QuestionsController } from './questions.controller'; import { TestsModule } from '../tests/tests.module'; import { QuestionsService } from './providers/questions.service'; +import { QuestionsCommandProvider } from './providers/questions-command.provider'; +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'; @Module({ imports: [TypeOrmModule.forFeature([Question, Choice]), TestsModule], controllers: [QuestionsController], - providers: [QuestionsService], - exports: [QuestionsService], + providers: [ + QuestionsCommandProvider, + QuestionsQueryProvider, + QuestionsService, + ChoicesCommandProvider, + ChoicesQueryProvider, + ChoicesService, + ], + exports: [QuestionsService, ChoicesService], }) export class QuestionsModule {} diff --git a/src/modules/tests/providers/tests-command.provider.ts b/src/modules/tests/providers/tests-command.provider.ts index c895529..99db52b 100644 --- a/src/modules/tests/providers/tests-command.provider.ts +++ b/src/modules/tests/providers/tests-command.provider.ts @@ -1,9 +1,4 @@ -import { - BadRequestException, - HttpStatus, - Injectable, - NotFoundException, -} from '@nestjs/common'; +import { BadRequestException, HttpStatus, Injectable } from '@nestjs/common'; import { CreateTestDTO } from '../dtos/create-test.dto'; import { InjectRepository } from '@nestjs/typeorm'; import { Test } from '../entities/test.entity'; @@ -49,7 +44,9 @@ export class TestsCommandProvider { await this.testRepository.save(newTest); return new AppResponse( newTest, - this.i18nService.translate('tests.messages.createSuccess'), + this.i18nService.translate('common.messages.createSuccess', { + args: { entity: '$t(tests.fields.test)' }, + }), ); } catch (err) { throw new AppTimeoutException(err); @@ -60,7 +57,11 @@ export class TestsCommandProvider { const test = await this.testRepository.findOneBy({ id }); if (!test) { - throw new NotFoundException('tests.errors.testNotFound'); + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(tests.fields.test)' }, + ); } const accessType = updateTestDto.accessType ?? test.accessType; @@ -91,7 +92,12 @@ export class TestsCommandProvider { throw new AppTimeoutException(err); } - if (!test) throw new NotFoundException('tests.errors.testNotFound'); + if (!test) + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(tests.fields.test)' }, + ); try { await this.testRepository.delete(test.id); diff --git a/src/modules/tests/providers/tests-query.provider.ts b/src/modules/tests/providers/tests-query.provider.ts index 9961a32..3db56f4 100644 --- a/src/modules/tests/providers/tests-query.provider.ts +++ b/src/modules/tests/providers/tests-query.provider.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { HttpStatus, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { PaginatedResponse } from '@/common/responses'; @@ -7,6 +7,7 @@ import { FindTestsDTO } from '../dtos/find-tests.dto'; import { filterTests } from '../helpers/filter-tests.helper'; import Utils from '@/common/utils'; import { Test } from '../entities/test.entity'; +import { AppException } from '@/common/exceptions/app.exception'; @Injectable() export class TestsQueryProvider { @@ -90,7 +91,11 @@ export class TestsQueryProvider { } if (!test) { - throw new NotFoundException('tests.errors.testNotFound'); + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(tests.fields.test)' }, + ); } return test; diff --git a/src/modules/users/dto/update-profile.dto.ts b/src/modules/users/dto/update-profile.dto.ts index 4b7420f..cc2ebcf 100644 --- a/src/modules/users/dto/update-profile.dto.ts +++ b/src/modules/users/dto/update-profile.dto.ts @@ -50,7 +50,7 @@ export class UpdateProfileDTO { @MaxLength(96, { message: t('validation.maxLengthField', { field: '$t(users.fields.lastName)', - min: 3, + max: 96, }), }) lastName?: string;