From deaa479c8057bdec48ddcaf0320c64419cf84541 Mon Sep 17 00:00:00 2001 From: radmehr Date: Wed, 5 Aug 2026 00:26:24 +0330 Subject: [PATCH] feat: complete question transitions admin routes --- src/app.module.ts | 15 ++- src/modules/admin/admin.module.ts | 2 + .../http/admin-choices.post.endpoints.http | 10 +- .../admin-question-transitions.controller.ts | 65 ++++++++++ ...n-question-transitions.post.endpoints.http | 12 ++ .../http/admin-questions.post.endpoints.http | 10 +- .../auth/http/verify-otp.post.endpoints.http | 2 +- .../dtos/find-question-transitions.dto.ts | 33 +++++ .../question-transition-sort-field.enum.ts | 7 + .../filter-question-transitions.helper.ts | 19 +++ .../question-range-rule-command.provider.ts | 9 +- .../question-transition-command.provider.ts | 2 + .../question-transition-query.provider.ts | 121 ++++++++++++++++++ .../transition/question-transition.service.ts | 51 ++++++++ .../check-transition-db.provider.ts | 6 +- src/modules/questions/questions.module.ts | 13 +- 16 files changed, 353 insertions(+), 24 deletions(-) create mode 100644 src/modules/admin/question-transitions/admin-question-transitions.controller.ts create mode 100644 src/modules/admin/question-transitions/http/admin-question-transitions.post.endpoints.http create mode 100644 src/modules/questions/dtos/find-question-transitions.dto.ts create mode 100644 src/modules/questions/enums/question-transition-sort-field.enum.ts create mode 100644 src/modules/questions/helpers/filter-question-transitions.helper.ts diff --git a/src/app.module.ts b/src/app.module.ts index c138d45..fc03307 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -21,8 +21,8 @@ import { QuestionsModule } from './modules/questions/questions.module'; import { SubmissionsModule } from './modules/submissions/submissions.module'; import { ScoringModule } from './modules/scoring/scoring.module'; import { AdminModule } from './modules/admin/admin.module'; -import { ThrottlerModule } from '@nestjs/throttler'; import { AppThrottlerGuard } from './common/guards/app-throttler.guard'; +import { ThrottlerModule } from '@nestjs/throttler'; const ENV = process.env.NODE_ENV; @@ -71,12 +71,13 @@ const ENV = process.env.NODE_ENV; ScheduleModule.forRoot(), // Throttler - ThrottlerModule.forRoot([ - { name: 'default', limit: 100, ttl: 60_000 }, - { name: 'login', limit: 5, ttl: 60_000 }, - { name: 'otp', limit: 3, ttl: 300_000 }, - { name: 'admin', limit: 30, ttl: 60_000 }, - ]), + // ThrottlerModule.forRoot([ + // { name: 'default', limit: 100, ttl: 60_000 }, + // { name: 'login', limit: 5, ttl: 60_000 }, + // { name: 'otp', limit: 3, ttl: 300_000 }, + // { name: 'admin', limit: 30, ttl: 60_000 }, + // ]), + ThrottlerModule.forRoot([]), /** * Local Modules diff --git a/src/modules/admin/admin.module.ts b/src/modules/admin/admin.module.ts index 8ee0233..4fb9787 100644 --- a/src/modules/admin/admin.module.ts +++ b/src/modules/admin/admin.module.ts @@ -11,6 +11,7 @@ import { AdminQuestionsController } from './questions/admin-questions.controller import { AdminChoicesController } from './choices/admin-choices.controller'; import { ScoringModule } from '../scoring/scoring.module'; import { AdminScoringController } from './scoring/admin-scoring.controller'; +import { AdminQuestionTransitionsController } from './question-transitions/admin-question-transitions.controller'; @Module({ imports: [ @@ -27,6 +28,7 @@ import { AdminScoringController } from './scoring/admin-scoring.controller'; AdminQuestionsController, AdminChoicesController, AdminScoringController, + AdminQuestionTransitionsController, ], providers: [AdminAuthService], }) diff --git a/src/modules/admin/choices/http/admin-choices.post.endpoints.http b/src/modules/admin/choices/http/admin-choices.post.endpoints.http index e640039..58d8ab0 100644 --- a/src/modules/admin/choices/http/admin-choices.post.endpoints.http +++ b/src/modules/admin/choices/http/admin-choices.post.endpoints.http @@ -3,9 +3,9 @@ Authorization: Bearer {{$global.accessToken}} Content-Type: application/json { - "question": 2, - "text": "Option 3", - "value": 3, - "order": 3, - "score": 6 + "question": 6, + "text": "Option 2", + "value": 1, + "order": 2, + "score": 8 } \ No newline at end of file diff --git a/src/modules/admin/question-transitions/admin-question-transitions.controller.ts b/src/modules/admin/question-transitions/admin-question-transitions.controller.ts new file mode 100644 index 0000000..19c1fc0 --- /dev/null +++ b/src/modules/admin/question-transitions/admin-question-transitions.controller.ts @@ -0,0 +1,65 @@ +import { Role } from '@/common/enums/roles.enum'; +import { Roles } from '@/modules/auth/decorators/roles.decorator'; +import { CreateQuestionTransitionDTO } from '@/modules/questions/dtos/create-question-transition.dto'; +import { FindQuestionTransitionsDTO } from '@/modules/questions/dtos/find-question-transitions.dto'; +import { UpdateQuestionTransitionDTO } from '@/modules/questions/dtos/update-question-transition.dto'; +import { QuestionTransitionService } from '@/modules/questions/providers/transition/question-transition.service'; +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { Throttle } from '@nestjs/throttler'; + +@Controller('admin/question-transitions') +@Roles(Role.SUPERUSER, Role.ADMIN) +@Throttle({ admin: {} }) +export class AdminQuestionTransitionsController { + constructor( + /** + * Inject Question Transitions Service + */ + private readonly questionTransitionService: QuestionTransitionService, + ) {} + + @Post() + public async createTransition( + @Body() createQuestionTransitionDto: CreateQuestionTransitionDTO, + ) { + return await this.questionTransitionService.createOne( + createQuestionTransitionDto, + ); + } + + @Patch(':id') + public async updateTransition( + @Param('id', ParseIntPipe) id: number, + @Body() updateQuestionTransitionDto: UpdateQuestionTransitionDTO, + ) { + return await this.questionTransitionService.updateOne( + id, + updateQuestionTransitionDto, + ); + } + + @Delete(':id') + public async deleteTransition(@Param('id', ParseIntPipe) id: number) { + return await this.questionTransitionService.deleteOne(id); + } + + @Get() + public async getAllTransitions(@Query() query: FindQuestionTransitionsDTO) { + return await this.questionTransitionService.findAllForAdmin(query); + } + + @Get(':id') + public async getTransitionById(@Param('id', ParseIntPipe) id: number) { + return await this.questionTransitionService.findOneByIdForAdmin(id); + } +} diff --git a/src/modules/admin/question-transitions/http/admin-question-transitions.post.endpoints.http b/src/modules/admin/question-transitions/http/admin-question-transitions.post.endpoints.http new file mode 100644 index 0000000..83a4631 --- /dev/null +++ b/src/modules/admin/question-transitions/http/admin-question-transitions.post.endpoints.http @@ -0,0 +1,12 @@ +POST {{baseUrl}}/admin/question-transitions/ +Authorization: Bearer {{$global.accessToken}} +Content-Type: application/json + +{ + "test": 1, + "type": "end", + "fromQuestion": 7 + // "toQuestion": 7 + // "conditionValue": {"min": 51, "max": 100} + // "conditionValue": {"choiceId": 5} +} \ No newline at end of file diff --git a/src/modules/admin/questions/http/admin-questions.post.endpoints.http b/src/modules/admin/questions/http/admin-questions.post.endpoints.http index 8faf5ea..7611cfd 100644 --- a/src/modules/admin/questions/http/admin-questions.post.endpoints.http +++ b/src/modules/admin/questions/http/admin-questions.post.endpoints.http @@ -4,10 +4,10 @@ Content-Type: application/json { "test": 1, - "content": "سوال سوم", + "content": "سوال هفتم", "required": true, - "type": "numeric", - "metadata": { "min": 1, "max": 20 } + "type": "numeric" + // "metadata": { "min": -10, "max": 10 } } POST {{baseUrl}}/admin/questions/range-rules @@ -15,8 +15,8 @@ Authorization: Bearer {{$global.accessToken}} Content-Type: application/json { - "question": 3, + "question": 5, "min": 1, "max": 10, - "score": 10 + "score": 8 } \ 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 91edb9e..f8674ef 100644 --- a/src/modules/auth/http/verify-otp.post.endpoints.http +++ b/src/modules/auth/http/verify-otp.post.endpoints.http @@ -3,7 +3,7 @@ Content-Type: application/json { "phone": "09333026363", - "otp": "87074" + "otp": "55486" } {{ diff --git a/src/modules/questions/dtos/find-question-transitions.dto.ts b/src/modules/questions/dtos/find-question-transitions.dto.ts new file mode 100644 index 0000000..2f9548b --- /dev/null +++ b/src/modules/questions/dtos/find-question-transitions.dto.ts @@ -0,0 +1,33 @@ +import { PaginationQueryDTO } from '@/common/dtos/pagination-query.dto'; +import { PartialType } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional } from 'class-validator'; +import { QuestionTransitionType } from '../enums/question-transition-type.enum'; +import { QuestionTransitionSortField } from '../enums/question-transition-sort-field.enum'; + +export class FindQuestionTransitionsDTO extends PartialType( + PaginationQueryDTO, +) { + @IsNumber() + @IsOptional() + id?: number; + + @IsNumber() + @IsOptional() + test?: number; + + @IsNumber() + @IsOptional() + fromQuestion?: number; + + @IsNumber() + @IsOptional() + toQuestion?: number; + + @IsEnum(QuestionTransitionType) + @IsOptional() + type?: QuestionTransitionType; + + @IsEnum(QuestionTransitionSortField) + @IsOptional() + sortBy?: QuestionTransitionSortField; +} diff --git a/src/modules/questions/enums/question-transition-sort-field.enum.ts b/src/modules/questions/enums/question-transition-sort-field.enum.ts new file mode 100644 index 0000000..c21ed11 --- /dev/null +++ b/src/modules/questions/enums/question-transition-sort-field.enum.ts @@ -0,0 +1,7 @@ +export enum QuestionTransitionSortField { + ID = 'id', + TEST = 'test', + FROM_QUESTION = 'fromQuestion', + TO_QUESTION = 'toQuestion', + TYPE = 'type', +} diff --git a/src/modules/questions/helpers/filter-question-transitions.helper.ts b/src/modules/questions/helpers/filter-question-transitions.helper.ts new file mode 100644 index 0000000..b67d488 --- /dev/null +++ b/src/modules/questions/helpers/filter-question-transitions.helper.ts @@ -0,0 +1,19 @@ +import { FindOptionsWhere } from 'typeorm'; +import { FindQuestionTransitionsDTO } from '../dtos/find-question-transitions.dto'; +import { QuestionTransition } from '../entities/question-transition.entity'; + +export const filterQuestionTransitions = ( + query: FindQuestionTransitionsDTO, +) => { + const where: FindOptionsWhere = {}; + + if (query.id) { + where.id = query.id; + } + + if (query.type !== undefined) { + where.type = query.type; + } + + return where; +}; diff --git a/src/modules/questions/providers/range-rule/question-range-rule-command.provider.ts b/src/modules/questions/providers/range-rule/question-range-rule-command.provider.ts index 22177c3..6b6eac7 100644 --- a/src/modules/questions/providers/range-rule/question-range-rule-command.provider.ts +++ b/src/modules/questions/providers/range-rule/question-range-rule-command.provider.ts @@ -61,9 +61,14 @@ export class QuestionRangeRuleCommandProvider { ): Promise { const queryBuilder = this.questionRangeRulesRepository .createQueryBuilder('rule') - .where('rule.questionId = :question', { question }) + .where({ question: { id: 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)', + `( + (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 }, ); diff --git a/src/modules/questions/providers/transition/question-transition-command.provider.ts b/src/modules/questions/providers/transition/question-transition-command.provider.ts index 35aa7b4..15428c8 100644 --- a/src/modules/questions/providers/transition/question-transition-command.provider.ts +++ b/src/modules/questions/providers/transition/question-transition-command.provider.ts @@ -52,6 +52,8 @@ export class QuestionTransitionCommandProvider { } catch (err) { this.handleDBExceptions(err); } + + return newTransition; } public async updateOne( diff --git a/src/modules/questions/providers/transition/question-transition-query.provider.ts b/src/modules/questions/providers/transition/question-transition-query.provider.ts index e69de29..fad909c 100644 --- a/src/modules/questions/providers/transition/question-transition-query.provider.ts +++ b/src/modules/questions/providers/transition/question-transition-query.provider.ts @@ -0,0 +1,121 @@ +import { InjectRepository } from '@nestjs/typeorm'; +import { QuestionTransition } from '../../entities/question-transition.entity'; +import { Repository } from 'typeorm'; +import { HttpStatus, Injectable } from '@nestjs/common'; +import { FindQuestionTransitionsDTO } from '../../dtos/find-question-transitions.dto'; +import { filterQuestionTransitions } from '../../helpers/filter-question-transitions.helper'; +import Utils from '@/common/utils'; +import { Test } from '@/modules/tests/entities/test.entity'; +import { TestsService } from '@/modules/tests/providers/tests.service'; +import { Question } from '../../entities/question.entity'; +import { QuestionsQueryProvider } from '../questions-query.provider'; +import { PaginatedResponse } from '@/common/responses'; +import { AppTimeoutException } from '@/common/exceptions/app-timeout.exception'; +import { AppException } from '@/common/exceptions/app.exception'; + +@Injectable() +export class QuestionTransitionQueryProvider { + constructor( + /** + * Inject QuestionTransitions Repository + */ + @InjectRepository(QuestionTransition) + private readonly transitionsRepository: Repository, + + /** + * Inject Tests Service + */ + private readonly testsService: TestsService, + + /** + * Inject Questions Query Provider + */ + private readonly questionsQueryProvider: QuestionsQueryProvider, + ) {} + + public async findAllForAdmin(query: FindQuestionTransitionsDTO) { + const filters = filterQuestionTransitions(query); + const { limit, page, skip, take } = + Utils.PaginationUtils.getPaginationOptions(query); + + let selectedTest: Test | null = null; + let selectedFromQuestion: Question | null = null; + let selectedToQuestion: Question | null = null; + + if (query.test !== undefined) { + selectedTest = await this.testsService.adminGetOne(query.test); + } + + if (query.fromQuestion !== undefined) { + selectedFromQuestion = await this.questionsQueryProvider.findOneForAdmin({ + where: { id: query.fromQuestion }, + }); + } + + if (query.toQuestion !== undefined) { + selectedToQuestion = await this.questionsQueryProvider.findOneForAdmin({ + where: { id: query.toQuestion }, + }); + } + + try { + const [transitions, total] = + await this.transitionsRepository.findAndCount({ + where: { + ...filters, + ...(selectedTest && { + test: { + id: selectedTest.id, + }, + }), + ...(selectedFromQuestion && { + fromQuestion: { + id: selectedFromQuestion.id, + }, + }), + ...(selectedToQuestion && { + toQuestion: { + id: selectedToQuestion.id, + }, + }), + }, + loadRelationIds: true, + skip, + take, + order: query.sortBy + ? { + [query.sortBy]: query.order ?? 'ASC', + } + : { id: 'DESC' }, + }); + + return new PaginatedResponse(transitions, { + page, + limit, + total, + }); + } catch (err) { + throw new AppTimeoutException(err); + } + } + + public async findOneByIdForAdmin(id: number) { + let transition: QuestionTransition | null = null; + + try { + transition = await this.transitionsRepository.findOneBy({ id }); + } catch (err) { + throw new AppTimeoutException(err); + } + + if (!transition) { + throw new AppException( + 'common.errors.entityNotFound', + HttpStatus.NOT_FOUND, + { entity: '$t(questions.fields.questionTransition)' }, + ); + } + + return transition; + } +} diff --git a/src/modules/questions/providers/transition/question-transition.service.ts b/src/modules/questions/providers/transition/question-transition.service.ts index e69de29..fcf4ffb 100644 --- a/src/modules/questions/providers/transition/question-transition.service.ts +++ b/src/modules/questions/providers/transition/question-transition.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { QuestionTransitionCommandProvider } from './question-transition-command.provider'; +import { CreateQuestionTransitionDTO } from '../../dtos/create-question-transition.dto'; +import { UpdateQuestionTransitionDTO } from '../../dtos/update-question-transition.dto'; +import { FindQuestionTransitionsDTO } from '../../dtos/find-question-transitions.dto'; +import { QuestionTransitionQueryProvider } from './question-transition-query.provider'; + +@Injectable() +export class QuestionTransitionService { + constructor( + /** + * Inject QuestionTransition Command Provider + */ + private readonly questionTransitionCommandProvider: QuestionTransitionCommandProvider, + + /** + * Inject QuestionTransition Query Provider + */ + private readonly questionTransitionQueryProvider: QuestionTransitionQueryProvider, + ) {} + + public async createOne( + createQuestionTransitionDto: CreateQuestionTransitionDTO, + ) { + return await this.questionTransitionCommandProvider.createOne( + createQuestionTransitionDto, + ); + } + + public async updateOne( + id: number, + updateQuestionTransitionDto: UpdateQuestionTransitionDTO, + ) { + return await this.questionTransitionCommandProvider.updateOne( + id, + updateQuestionTransitionDto, + ); + } + + public async deleteOne(id: number) { + return await this.questionTransitionCommandProvider.deleteOne(id); + } + + public async findAllForAdmin(query: FindQuestionTransitionsDTO) { + return await this.questionTransitionQueryProvider.findAllForAdmin(query); + } + + public async findOneByIdForAdmin(id: number) { + return await this.questionTransitionQueryProvider.findOneByIdForAdmin(id); + } +} diff --git a/src/modules/questions/providers/transition/validations/check-transition-db.provider.ts b/src/modules/questions/providers/transition/validations/check-transition-db.provider.ts index 3e161a2..0517013 100644 --- a/src/modules/questions/providers/transition/validations/check-transition-db.provider.ts +++ b/src/modules/questions/providers/transition/validations/check-transition-db.provider.ts @@ -140,7 +140,7 @@ export class CheckTransitionDBProvider { }); if (options.type === QuestionTransitionType.CHOICE) { - qb.andWhere(`t.conditionValue ->> 'choiceId' = :choiceId`, { + qb.andWhere(`t.condition ->> 'choiceId' = :choiceId`, { choiceId: String( (options.conditionValue as SingleChoiceCondition).choiceId, ), @@ -148,9 +148,9 @@ export class CheckTransitionDBProvider { } if (options.type === QuestionTransitionType.NUMBER_RANGE) { - qb.andWhere(`t.conditionValue ->> 'min' = :min`, { + qb.andWhere(`t.condition ->> 'min' = :min`, { min: String((options.conditionValue as NumericCondition).min), - }).andWhere(`t.conditionValue ->> 'max' = :max`, { + }).andWhere(`t.condition ->> 'max' = :max`, { max: String((options.conditionValue as NumericCondition).max), }); } diff --git a/src/modules/questions/questions.module.ts b/src/modules/questions/questions.module.ts index 5feaa99..b81fb83 100644 --- a/src/modules/questions/questions.module.ts +++ b/src/modules/questions/questions.module.ts @@ -17,6 +17,9 @@ import { QuestionRangeRuleQueryProvider } from './providers/range-rule/question- import { QuestionTransition } from './entities/question-transition.entity'; import { CheckTransitionDBProvider } from './providers/transition/validations/check-transition-db.provider'; import { QuestionTransitionValidatorProvider } from './providers/transition/validations/question-transition-validator.provider'; +import { QuestionTransitionCommandProvider } from './providers/transition/question-transition-command.provider'; +import { QuestionTransitionQueryProvider } from './providers/transition/question-transition-query.provider'; +import { QuestionTransitionService } from './providers/transition/question-transition.service'; @Module({ imports: [ @@ -48,7 +51,15 @@ import { QuestionTransitionValidatorProvider } from './providers/transition/vali // Question Transitions CheckTransitionDBProvider, QuestionTransitionValidatorProvider, + QuestionTransitionCommandProvider, + QuestionTransitionQueryProvider, + QuestionTransitionService, + ], + exports: [ + QuestionsService, + ChoicesService, + QuestionRangeRuleService, + QuestionTransitionService, ], - exports: [QuestionsService, ChoicesService, QuestionRangeRuleService], }) export class QuestionsModule {}