feat add questions module and admin routes
This commit is contained in:
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"test": "Test",
|
||||||
|
"type": "Type",
|
||||||
|
"content": "Content",
|
||||||
|
"order": "Order",
|
||||||
|
"required": "Required",
|
||||||
|
"metadata": "Metadata"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"uniqueOrderConstraint": "Order value should be unique.",
|
||||||
|
"questionNotFound": "Question not found."
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"createSuccess": "Question Created Successfully."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"test": "تست",
|
||||||
|
"type": "نوع",
|
||||||
|
"content": "محتوا",
|
||||||
|
"order": "اولویت",
|
||||||
|
"required": "اجباری",
|
||||||
|
"metadata": "تنظیمات"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"uniqueOrderConstraint": "مقدار اولویت تکراری است.",
|
||||||
|
"questionNotFound": "Question not found."
|
||||||
|
},
|
||||||
|
"messages": {
|
||||||
|
"createSuccess": "سوال با موفقیت ایجاد شد."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,22 @@ import { HashingModule } from '@/common/modules/hashing/hashing.module';
|
|||||||
import { UsersModule } from '../users/users.module';
|
import { UsersModule } from '../users/users.module';
|
||||||
import { AdminAuthController } from './auth/admin-auth.controller';
|
import { AdminAuthController } from './auth/admin-auth.controller';
|
||||||
import { AdminAuthService } from './auth/providers/admin-auth.service';
|
import { AdminAuthService } from './auth/providers/admin-auth.service';
|
||||||
|
import { QuestionsModule } from '../questions/questions.module';
|
||||||
|
import { AdminQuestionsController } from './questions/admin-questions.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TestsModule, AuthModule, HashingModule, UsersModule],
|
imports: [
|
||||||
controllers: [AdminTestsController, AdminAuthController],
|
TestsModule,
|
||||||
|
QuestionsModule,
|
||||||
|
AuthModule,
|
||||||
|
HashingModule,
|
||||||
|
UsersModule,
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
AdminTestsController,
|
||||||
|
AdminAuthController,
|
||||||
|
AdminQuestionsController,
|
||||||
|
],
|
||||||
providers: [AdminAuthService],
|
providers: [AdminAuthService],
|
||||||
})
|
})
|
||||||
export class AdminModule {}
|
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
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"phone": "09121111111",
|
"phone": "09365858721",
|
||||||
"otp": "69210"
|
"otp": "40023"
|
||||||
}
|
}
|
||||||
@@ -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,
|
Column,
|
||||||
CreateDateColumn,
|
CreateDateColumn,
|
||||||
Entity,
|
Entity,
|
||||||
|
Index,
|
||||||
ManyToOne,
|
ManyToOne,
|
||||||
OneToMany,
|
OneToMany,
|
||||||
PrimaryGeneratedColumn,
|
PrimaryGeneratedColumn,
|
||||||
@@ -10,8 +11,10 @@ import {
|
|||||||
import { QuestionType } from '../enums/question-type.enum';
|
import { QuestionType } from '../enums/question-type.enum';
|
||||||
import type { QuestionMetadata } from '../types/question-metadata.type';
|
import type { QuestionMetadata } from '../types/question-metadata.type';
|
||||||
import { Choice } from './choice.entity';
|
import { Choice } from './choice.entity';
|
||||||
|
import { UNIQUE_TEST_ORDER_IDX } from '../constants';
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
|
@Index(UNIQUE_TEST_ORDER_IDX, ['test', 'order'], { unique: true })
|
||||||
export class Question {
|
export class Question {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id!: number;
|
id!: number;
|
||||||
@@ -28,7 +31,7 @@ export class Question {
|
|||||||
})
|
})
|
||||||
type!: QuestionType;
|
type!: QuestionType;
|
||||||
|
|
||||||
@Column({ type: 'text' })
|
@Column({ type: 'text', nullable: false })
|
||||||
content!: string;
|
content!: string;
|
||||||
|
|
||||||
@Column({ type: 'int', nullable: false })
|
@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 {}
|
||||||
@@ -2,8 +2,14 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { Question } from './entities/question.entity';
|
import { Question } from './entities/question.entity';
|
||||||
import { Choice } from './entities/choice.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({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Question, Choice])],
|
imports: [TypeOrmModule.forFeature([Question, Choice]), TestsModule],
|
||||||
|
controllers: [QuestionsController],
|
||||||
|
providers: [QuestionsService],
|
||||||
|
exports: [QuestionsService],
|
||||||
})
|
})
|
||||||
export class QuestionsModule {}
|
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()
|
@Entity()
|
||||||
export class ResultRange {
|
export class ResultRange {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id: number;
|
id!: number;
|
||||||
|
|
||||||
@ManyToOne(() => Test, (test) => test.resultRanges, {
|
@ManyToOne(() => Test, (test) => test.resultRanges, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
})
|
})
|
||||||
test: Test;
|
test!: Test;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'int',
|
type: 'int',
|
||||||
nullable: false,
|
nullable: false,
|
||||||
})
|
})
|
||||||
minScore: number;
|
minScore!: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'int',
|
type: 'int',
|
||||||
nullable: false,
|
nullable: false,
|
||||||
})
|
})
|
||||||
maxScore: number;
|
maxScore!: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
length: 255,
|
length: 255,
|
||||||
nullable: false,
|
nullable: false,
|
||||||
})
|
})
|
||||||
title: string;
|
title!: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'text',
|
type: 'text',
|
||||||
nullable: false,
|
nullable: false,
|
||||||
})
|
})
|
||||||
description: string;
|
description!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { FindOptionsWhere, ILike } from 'typeorm';
|
import { FindOptionsWhere, ILike } from 'typeorm';
|
||||||
import { FindTestsDTO } from '../dtos/find-tests.dto';
|
import { FindTestsDTO } from '../dtos/find-tests.dto';
|
||||||
|
import { Test } from '../entities/test.entity';
|
||||||
|
|
||||||
export const filterTests = (query: FindTestsDTO) => {
|
export const filterTests = (query: FindTestsDTO) => {
|
||||||
const where: FindOptionsWhere<any> = {};
|
const where: FindOptionsWhere<Test> = {};
|
||||||
|
|
||||||
if (query.id) {
|
if (query.id) {
|
||||||
where.id = query.id;
|
where.id = query.id;
|
||||||
|
|||||||
@@ -70,9 +70,7 @@ export class TestsCommandProvider {
|
|||||||
(accessType === AccessType.PAID && !price) ||
|
(accessType === AccessType.PAID && !price) ||
|
||||||
(accessType !== AccessType.PAID && price)
|
(accessType !== AccessType.PAID && price)
|
||||||
) {
|
) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException('tests.errors.priceAccessConstraint');
|
||||||
this.i18nService.translate('tests.errors.priceAccessConstraint'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(test, updateTestDto);
|
Object.assign(test, updateTestDto);
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ export class PriceAccessTypeConstraint implements ValidatorConstraintInterface {
|
|||||||
_: any,
|
_: any,
|
||||||
validationArguments?: ValidationArguments,
|
validationArguments?: ValidationArguments,
|
||||||
): Promise<boolean> | boolean {
|
): Promise<boolean> | boolean {
|
||||||
console.log('This is happening');
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
const obj = validationArguments?.object as any;
|
const obj = validationArguments?.object as any;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
@Entity()
|
@Entity()
|
||||||
export class User {
|
export class User {
|
||||||
@PrimaryGeneratedColumn()
|
@PrimaryGeneratedColumn()
|
||||||
id: number;
|
id!: number;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
@@ -34,7 +34,7 @@ export class User {
|
|||||||
nullable: false,
|
nullable: false,
|
||||||
unique: true,
|
unique: true,
|
||||||
})
|
})
|
||||||
phone: string;
|
phone!: string;
|
||||||
|
|
||||||
@Column({
|
@Column({
|
||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
@@ -48,11 +48,11 @@ export class User {
|
|||||||
enum: Role,
|
enum: Role,
|
||||||
default: Role.USER,
|
default: Role.USER,
|
||||||
})
|
})
|
||||||
role: Role;
|
role!: Role;
|
||||||
|
|
||||||
@CreateDateColumn()
|
@CreateDateColumn()
|
||||||
createdAt: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
@UpdateDateColumn()
|
@UpdateDateColumn()
|
||||||
updatedAt: Date;
|
updatedAt!: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user