feat add questions module and admin routes

This commit is contained in:
2026-06-15 07:34:41 +03:30
parent d1fecf62ad
commit 0eea63fec1
28 changed files with 636 additions and 23 deletions
+14 -2
View File
@@ -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 {}
@@ -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);
}
}
@@ -0,0 +1,2 @@
DELETE http://localhost:3000/admin/questions/11
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxNDQ2Njg2LCJleHAiOjE3ODE0NTAyODYsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.3L6jYtNwlcLr9A9lJo6pOiTIi2rHKIqSXWphH4mgbSU
@@ -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
@@ -0,0 +1,9 @@
PATCH http://localhost:3000/admin/questions/1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgxNDIyMjkxLCJleHAiOjE3ODE0MjU4OTEsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.djDjd0v7UwfITA4Bh-PC5G830o9wu2CgRKRenwuJJ4E
Content-Type: application/json
{
"metadata": {
"allowOther": true
}
}
@@ -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 }
}
@@ -2,6 +2,6 @@ POST http://localhost:3000/auth/verify-otp
Content-Type: application/json
{
"phone": "09121111111",
"otp": "69210"
"phone": "09365858721",
"otp": "40023"
}
+1
View File
@@ -0,0 +1 @@
export const UNIQUE_TEST_ORDER_IDX = 'UNQ_TEST_ORDER_IDX';
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,7 @@
import { IsBoolean, IsOptional } from 'class-validator';
export class MultiChoiceMetadataDTO {
@IsBoolean()
@IsOptional()
allowOther?: boolean;
}
@@ -0,0 +1,13 @@
import { IsNumber, IsOptional } from 'class-validator';
export class NumericMetadataDTO {
@IsNumber()
min!: number;
@IsNumber()
max!: number;
@IsNumber()
@IsOptional()
step?: number;
}
@@ -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;
}
@@ -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 })
@@ -0,0 +1,8 @@
export enum QuestionSortField {
ID = 'id',
TEST = 'test',
TYPE = 'type',
CONTENT = 'content',
ORDER = 'order',
CREATED_AT = 'createdAt',
}
@@ -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<Question> = {};
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;
};
@@ -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<ValidationError[]> => {
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,
});
};
@@ -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<Question>,
/**
* 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);
}
}
}
@@ -0,0 +1,4 @@
import { Controller } from '@nestjs/common';
@Controller('questions')
export class QuestionsController {}
+7 -1
View File
@@ -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 {}
@@ -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<boolean> {
// 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'
);
}
}
@@ -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;
}
@@ -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<any> = {};
const where: FindOptionsWhere<Test> = {};
if (query.id) {
where.id = query.id;
@@ -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);
@@ -12,8 +12,6 @@ export class PriceAccessTypeConstraint implements ValidatorConstraintInterface {
_: any,
validationArguments?: ValidationArguments,
): Promise<boolean> | 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
+5 -5
View File
@@ -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;
}