feat: complete question transitions admin routes

This commit is contained in:
2026-08-05 00:26:24 +03:30
parent a3400f66c5
commit deaa479c80
16 changed files with 353 additions and 24 deletions
+8 -7
View File
@@ -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
+2
View File
@@ -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],
})
@@ -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
}
@@ -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);
}
}
@@ -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}
}
@@ -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
}
@@ -3,7 +3,7 @@ Content-Type: application/json
{
"phone": "09333026363",
"otp": "87074"
"otp": "55486"
}
{{
@@ -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;
}
@@ -0,0 +1,7 @@
export enum QuestionTransitionSortField {
ID = 'id',
TEST = 'test',
FROM_QUESTION = 'fromQuestion',
TO_QUESTION = 'toQuestion',
TYPE = 'type',
}
@@ -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<QuestionTransition> = {};
if (query.id) {
where.id = query.id;
}
if (query.type !== undefined) {
where.type = query.type;
}
return where;
};
@@ -61,9 +61,14 @@ export class QuestionRangeRuleCommandProvider {
): Promise<void> {
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 },
);
@@ -52,6 +52,8 @@ export class QuestionTransitionCommandProvider {
} catch (err) {
this.handleDBExceptions(err);
}
return newTransition;
}
public async updateOne(
@@ -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<QuestionTransition>,
/**
* 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;
}
}
@@ -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);
}
}
@@ -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),
});
}
+12 -1
View File
@@ -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 {}