feat: add admin tests read and update endpoints

This commit is contained in:
2026-06-06 13:33:56 +03:30
parent 39e6c96113
commit 28c1a4831e
31 changed files with 330 additions and 101 deletions
+3 -1
View File
@@ -7,7 +7,9 @@
"isActive": "Is Active"
},
"errors": {
"intPrice": "Price should be an integer."
"intPrice": "Price should be an integer.",
"testNotFound": "Test not found.",
"priceAccessConstraint": "Test's price depends on access type of 'Paid'."
},
"messages": {
"createSuccess": "Test Created Successfully."
+3 -1
View File
@@ -7,7 +7,9 @@
"isActive": "فعال بودن"
},
"errors": {
"intPrice": "قیمت باید عدد صحیح باشد."
"intPrice": "قیمت باید عدد صحیح باشد.",
"testNotFound": "تست مورد نظر یافت نشد.",
"priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است."
},
"messages": {
"createSuccess": "تست با موفقیت ایجاد شد."
@@ -19,5 +19,5 @@ export class AdminLoginDTO extends PhoneDTO {
field: '$t(auth.fields.password)',
}),
})
password: string;
password!: string;
}
@@ -2,6 +2,6 @@ POST http://localhost:3000/admin/auth/login
Content-Type: application/json
{
"phone": "0933302636",
"phone": "09333026363",
"password": "Password1@"
}
@@ -1,10 +1,20 @@
import { Role } from '@/common/enums/roles.enum';
import { Roles } from '@/modules/auth/decorators/roles.decorator';
import { CreateTestDTO } from '@/modules/tests/dtos/create-test.dto';
import { UpdateTestDTO } from '@/modules/tests/dtos/update-test.dto';
import { TestsService } from '@/modules/tests/providers/tests.service';
import { Body, Controller, Post } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
ParseIntPipe,
Patch,
Post,
} from '@nestjs/common';
@Controller('admin/tests')
@Roles(Role.SUPERUSER, Role.ADMIN)
export class AdminTestsController {
constructor(
/**
@@ -14,8 +24,25 @@ export class AdminTestsController {
) {}
@Post()
@Roles(Role.SUPERUSER, Role.ADMIN)
public async createTest(@Body() createTestDto: CreateTestDTO) {
return await this.testsService.createOne(createTestDto);
}
@Get()
public async getAllTests() {
return await this.testsService.adminGetAll();
}
@Get(':id')
public async getTestById(@Param('id', ParseIntPipe) id: number) {
return await this.testsService.adminGetOne(id);
}
@Patch(':id')
public async updateTestById(
@Param('id', ParseIntPipe) id: number,
@Body() updateTestDto: UpdateTestDTO,
) {
return await this.testsService.updateOne(id, updateTestDto);
}
}
@@ -0,0 +1,5 @@
GET http://localhost:3000/admin/tests
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgwMzk4MzI0LCJleHAiOjE3ODA0MDE5MjQsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.s3NAErSAjMTz2s27obas3TEi5Og2HPeLgWCoIyzmzII
GET http://localhost:3000/admin/tests/4
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgwMzk4MzI0LCJleHAiOjE3ODA0MDE5MjQsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.s3NAErSAjMTz2s27obas3TEi5Og2HPeLgWCoIyzmzII
@@ -0,0 +1,8 @@
PATCH http://localhost:3000/admin/tests/4
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgwNDk0NDQ1LCJleHAiOjE3ODA0OTgwNDUsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.Sy-Er8DXzEUCD6Z-GZav0XKmpIqf0sXeBkUzjIf4Ypw
Content-Type: application/json
{
"accessType": "login",
"isActive": false
}
@@ -0,0 +1,11 @@
POST http://localhost:3000/admin/tests/
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgwNDk0NDQ1LCJleHAiOjE3ODA0OTgwNDUsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.Sy-Er8DXzEUCD6Z-GZav0XKmpIqf0sXeBkUzjIf4Ypw
{
"title": "Test Five",
"description": "Description Five",
"accessType": "login",
"price": "1234000",
"isActive": true
}
@@ -1,11 +0,0 @@
POST http://localhost:3000/admin/tests/
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgwMjIzNDExLCJleHAiOjE3ODAyMjcwMTEsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.FNTIrk6FKO8P88yKnRCybZ6E5N3KlJaCHqZt5DMVgds
{
"title": "Test Three",
"description": "Description Three",
"accessType": "free",
"price": "1234000",
"isActive": true
}
+1 -1
View File
@@ -23,7 +23,7 @@ export class PhoneDTO {
}),
})
@Transform(({ value }) => Utils.StringUtils.normalizePhone(value as string))
phone: string;
phone!: string;
}
export class LoginDTO extends PhoneDTO {
+1 -1
View File
@@ -3,5 +3,5 @@ import { IsNotEmpty, IsString } from 'class-validator';
export class RefreshTokenDTO {
@IsNotEmpty()
@IsString()
refreshToken: string;
refreshToken!: string;
}
+1 -1
View File
@@ -9,5 +9,5 @@ export class VerifyOtpDTO extends PhoneDTO {
field: '$t(auth.fields.otp)',
}),
})
otp: string;
otp!: string;
}
@@ -3,5 +3,6 @@ Content-Type: application/json
lang: fa
{
"phone": "09121111111"
"phone": "09333026363",
"password": "Password1@"
}
@@ -2,10 +2,10 @@ POST http://localhost:3000/auth/refresh
Content-Type: application/json
// {
// "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgwMjIzNDExLCJleHAiOjE3ODAyMjcwMTEsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.FNTIrk6FKO8P88yKnRCybZ6E5N3KlJaCHqZt5DMVgds",
// "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJqdGkiOiJmZDRjZGViYS1iMjUzLTQ5MjgtOGIxYS0yZTJhZDMwOTIzYzMiLCJpYXQiOjE3ODAyMjM0MTEsImV4cCI6MTc4MTA4NzQxMSwiYXVkIjoibG9jYWxob3N0IiwiaXNzIjoibG9jYWxob3N0In0.EO0OpjQix794Y7yVZ8pjLH5HngYfCTwu3lonEfXtLIQ"
// "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJwaG9uZSI6IjA5MzMzMDI2MzYzIiwiaWF0IjoxNzgwNDk0NDQ1LCJleHAiOjE3ODA0OTgwNDUsImF1ZCI6ImxvY2FsaG9zdCIsImlzcyI6ImxvY2FsaG9zdCJ9.Sy-Er8DXzEUCD6Z-GZav0XKmpIqf0sXeBkUzjIf4Ypw",
// "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJqdGkiOiIxOTA0NDljZi01ZGFiLTQ4NDYtOGU0OS1jZmNkYjhlNjI1YmQiLCJpYXQiOjE3ODA0OTQ0NDUsImV4cCI6MTc4MTM1ODQ0NSwiYXVkIjoibG9jYWxob3N0IiwiaXNzIjoibG9jYWxob3N0In0.k3ATuOrenBFRVAGUy93Kv6iCkKF7vLB2DSohoH0JpXQ"
// }
{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJqdGkiOiIyZjQ1YzcxMi01ZGQzLTRhY2ItYjExYS04ZDA5YTc3YTVmYTciLCJpYXQiOjE3Nzk5NTY1OTksImV4cCI6MTc4MDgyMDU5OSwiYXVkIjoibG9jYWxob3N0IiwiaXNzIjoibG9jYWxob3N0In0.GX4kSCUCIvBVq9l4lQ8dkETvFZ1NwBIm9NGOIjBsQCc"
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEwLCJqdGkiOiJmMDA0YWNmZS03ZWU5LTQ1OTgtYjIwZi04NGM3YTA5NGU4NGIiLCJpYXQiOjE3ODAzODkxNjksImV4cCI6MTc4MTI1MzE2OSwiYXVkIjoibG9jYWxob3N0IiwiaXNzIjoibG9jYWxob3N0In0.yGKx-j0ommIdr1ARYZrXQyXRA-ZIi5Am7QS-VWlkc8w"
}
@@ -40,7 +40,6 @@ export class RefreshTokensProvider {
) {}
public async refreshTokens(refreshTokenDto: RefreshTokenDTO) {
try {
const payload = await this.jwtService.verifyAsync<RefreshTokenPayload>(
refreshTokenDto.refreshToken,
{
@@ -68,10 +67,5 @@ export class RefreshTokensProvider {
const newJti = randomUUID();
return await this.generateTokenProvider.generateTokens(user, newJti);
} catch (err) {
throw new UnauthorizedException('auth.errors.unauthorized', {
cause: err,
});
}
}
}
+6 -6
View File
@@ -11,7 +11,7 @@ import {
@Index(['phone', 'used'])
export class OTP {
@PrimaryGeneratedColumn()
id: number;
id!: number;
@Index()
@Column({
@@ -19,23 +19,23 @@ export class OTP {
length: 11,
nullable: false,
})
phone: string;
phone!: string;
@Column({
type: 'varchar',
nullable: false,
})
code: string;
code!: string;
@CreateDateColumn()
createdAt: Date;
createdAt!: Date;
@Index()
@Column({ type: 'timestamptz' })
expiresAt: Date;
expiresAt!: Date;
@Column({ type: 'boolean', default: false })
used: boolean;
used!: boolean;
@BeforeInsert()
setExpiry() {
@@ -4,23 +4,23 @@ import { Question } from './question.entity';
@Entity()
export class Choice {
@PrimaryGeneratedColumn()
id: number;
id!: number;
@ManyToOne(() => Question, (question) => question.choices, {
onDelete: 'CASCADE',
})
question: Question;
question!: Question;
@Column({
type: 'varchar',
length: 255,
nullable: false,
})
text: string;
text!: string;
@Column({ type: 'int', nullable: false })
value: number;
value!: number;
@Column({ type: 'int', nullable: false })
order: number;
order!: number;
}
@@ -14,35 +14,35 @@ import { Choice } from './choice.entity';
@Entity()
export class Question {
@PrimaryGeneratedColumn()
id: number;
id!: number;
@ManyToOne(() => Test, (test) => test.questions, {
onDelete: 'CASCADE',
})
test: Test;
test!: Test;
@Column({
type: 'enum',
enum: QuestionType,
default: QuestionType.MULTI_CHOICE,
})
type: QuestionType;
type!: QuestionType;
@Column({ type: 'text' })
content: string;
content!: string;
@Column({ type: 'int', nullable: false })
order: number;
order!: number;
@Column({ type: 'boolean', default: true })
required: boolean;
required!: boolean;
@Column({ type: 'jsonb', nullable: true })
metadata: QuestionMetadata;
metadata!: QuestionMetadata;
@OneToMany(() => Choice, (choice) => choice.question)
choices?: Array<Choice>;
@CreateDateColumn()
createdAt: Date;
createdAt!: Date;
}
@@ -10,11 +10,11 @@ import {
@Entity()
export class RuleSet {
@PrimaryGeneratedColumn()
id: number;
id!: number;
@OneToOne(() => Test, (test) => test.ruleSet)
@JoinColumn()
test: Test;
test!: Test;
@Column({ type: 'jsonb', nullable: false })
config: any;
@@ -6,15 +6,15 @@ import { Choice } from '@/modules/questions/entities/choice.entity';
@Entity()
export class SubmissionAnswer {
@PrimaryGeneratedColumn()
id: number;
id!: number;
@ManyToOne(() => Submission, (submission) => submission.answers, {
onDelete: 'CASCADE',
})
submission: Submission;
submission!: Submission;
@ManyToOne(() => Question, { onDelete: 'CASCADE' })
question: Question;
question!: Question;
@ManyToOne(() => Choice, { nullable: true, onDelete: 'CASCADE' })
choice?: Choice;
@@ -13,20 +13,20 @@ import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
@Check(`"probability" >= 0 AND "probability" <= 100`)
export class SubmissionResult {
@PrimaryGeneratedColumn()
id: number;
id!: number;
@ManyToOne(() => Submission, { onDelete: 'CASCADE' })
submission: Submission;
submission!: Submission;
@Column({ type: 'int', nullable: false })
score: number;
score!: number;
@Column({ type: 'int', nullable: false })
probability: number;
probability!: number;
@ManyToOne(() => ResultRange, { onDelete: 'CASCADE' })
resultRange: ResultRange;
resultRange!: ResultRange;
@CreateDateColumn()
createdAt: Date;
createdAt!: Date;
}
@@ -14,24 +14,24 @@ import { SubmissionAnswer } from './submission-answer.entity';
@Entity()
export class Submission {
@PrimaryGeneratedColumn()
id: number;
id!: number;
@ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' })
user?: User;
@ManyToOne(() => Test, { onDelete: 'CASCADE' })
test: Test;
test!: Test;
@Column({
type: 'enum',
enum: SubmissionStatus,
default: SubmissionStatus.IN_PROGRESS,
})
status: SubmissionStatus;
status!: SubmissionStatus;
@OneToMany(() => SubmissionAnswer, (answer) => answer.submission)
answers: Array<SubmissionAnswer>;
answers!: Array<SubmissionAnswer>;
@CreateDateColumn()
createdAt: Date;
createdAt!: Date;
}
@@ -0,0 +1,6 @@
import { Validate } from 'class-validator';
import { PriceAccessTypeConstraint } from '../validators/price-access-type-constraint.validator';
export const PriceAccessType = () => {
return Validate(PriceAccessTypeConstraint);
};
+6 -2
View File
@@ -9,6 +9,7 @@ import {
} from 'class-validator';
import { i18nValidationMessage as t } from 'nestjs-i18n';
import { AccessType } from '../enums/access-types.enum';
import { PriceAccessType } from '../decorators/price-access-type.decorator';
export class CreateTestDTO {
@IsString()
@@ -23,7 +24,7 @@ export class CreateTestDTO {
max: 255,
}),
})
title: string;
title!: string;
@IsString()
@IsNotEmpty({
@@ -31,7 +32,7 @@ export class CreateTestDTO {
field: '$t(tests.fields.description)',
}),
})
description: string;
description!: string;
@IsEnum(AccessType)
@IsOptional()
@@ -45,4 +46,7 @@ export class CreateTestDTO {
@IsBoolean()
@IsOptional()
isActive?: boolean;
@PriceAccessType()
private readonly _priceAccessValidation!: never;
}
@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateTestDTO } from './create-test.dto';
export class UpdateTestDTO extends PartialType(CreateTestDTO) {}
+16 -10
View File
@@ -1,4 +1,5 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
@@ -12,9 +13,14 @@ import { ResultRange } from '@/modules/scoring/entities/result-range.entity';
import { RuleSet } from '@/modules/scoring/entities/rule-set.entity';
@Entity()
@Check(`
("price" IS NULL AND "accessType" <> 'paid')
OR
("price" IS NOT NULL AND "accessType" = 'paid')
`)
export class Test {
@PrimaryGeneratedColumn()
id: number;
id!: number;
@Column({
type: 'varchar',
@@ -22,42 +28,42 @@ export class Test {
nullable: false,
unique: true,
})
title: string;
title!: string;
@Column({
type: 'text',
nullable: false,
})
description: string;
description!: string;
@Column({
type: 'enum',
enum: AccessType,
default: AccessType.LOGIN,
})
accessType: AccessType;
accessType!: AccessType;
@Column({
type: 'bigint',
nullable: true,
})
price: string;
price!: string;
@Column({
type: 'boolean',
default: false,
})
isActive: boolean;
isActive!: boolean;
@OneToMany(() => Question, (question) => question.test)
questions: Array<Question>;
questions!: Array<Question>;
@OneToOne(() => RuleSet, (ruleSet) => ruleSet.test)
ruleSet: RuleSet;
ruleSet!: RuleSet;
@OneToMany(() => ResultRange, (range) => range.test)
resultRanges: Array<ResultRange>;
resultRanges!: Array<ResultRange>;
@CreateDateColumn()
createdAt: Date;
createdAt!: Date;
}
@@ -0,0 +1,3 @@
GET http://localhost:3000/tests
GET http://localhost:3000/tests/4
@@ -1,6 +1,8 @@
import {
BadRequestException,
HttpStatus,
Injectable,
NotFoundException,
RequestTimeoutException,
} from '@nestjs/common';
import { Repository } from 'typeorm';
@@ -10,6 +12,8 @@ import { CreateTestDTO } from '../dtos/create-test.dto';
import { AppException } from '@/common/exceptions/app.exception';
import { AppResponse } from '@/common/responses';
import { I18nService } from 'nestjs-i18n';
import { UpdateTestDTO } from '../dtos/update-test.dto';
import { AccessType } from '../enums/access-types.enum';
@Injectable()
export class TestsService {
@@ -54,4 +58,106 @@ export class TestsService {
});
}
}
public async adminGetAll() {
try {
return await this.testRepository.find({ order: { createdAt: 'DESC' } });
} catch (err) {
throw new RequestTimeoutException('common.errors.requestTimeout', {
cause: err,
description: 'Error connecting to the database',
});
}
}
public async userGetAll() {
try {
return await this.testRepository.find({
where: { isActive: true },
select: {
id: true,
accessType: true,
description: true,
price: true,
title: true,
},
order: { createdAt: 'DESC' },
});
} catch (err) {
throw new RequestTimeoutException('common.errors.requestTimeout', {
cause: err,
description: 'Error connecting to the database',
});
}
}
public async adminGetOne(id: number) {
try {
return await this.testRepository.findOneBy({ id });
} catch (err) {
throw new RequestTimeoutException('common.errors.requestTimeout', {
cause: err,
description: 'Error connecting to the database',
});
}
}
public async userGetOne(id: number) {
let test: Test | null;
try {
test = await this.testRepository.findOne({
where: { id, isActive: true },
select: {
id: true,
accessType: true,
description: true,
price: true,
title: true,
},
});
} catch (err) {
throw new RequestTimeoutException('common.errors.requestTimeout', {
cause: err,
description: 'Error connecting to the database',
});
}
if (!test) {
throw new NotFoundException('tests.errors.testNotFound');
}
return test;
}
public async updateOne(id: number, updateTestDto: UpdateTestDTO) {
const test = await this.testRepository.findOneBy({ id });
if (!test) {
throw new NotFoundException('tests.errors.testNotFound');
}
const accessType = updateTestDto.accessType ?? test.accessType;
const price = updateTestDto.price ?? test.price;
if (
(accessType === AccessType.PAID && !price) ||
(accessType !== AccessType.PAID && price)
) {
throw new BadRequestException(
this.i18nService.translate('tests.errors.priceAccessConstraint'),
);
}
Object.assign(test, updateTestDto);
try {
return await this.testRepository.save(test);
} catch (err) {
throw new RequestTimeoutException('common.errors.requestTimeout', {
cause: err,
description: 'Error connecting to the database',
});
}
}
}
+25
View File
@@ -0,0 +1,25 @@
import { Controller, Get, Param, ParseIntPipe } from '@nestjs/common';
import { TestsService } from './providers/tests.service';
import { Public } from '../auth/decorators/public.decorator';
@Controller('tests')
export class TestsController {
constructor(
/**
* Inject Tests Service
*/
private readonly testsService: TestsService,
) {}
@Public()
@Get()
public async getAllTests() {
return await this.testsService.userGetAll();
}
@Public()
@Get(':id')
public async getTestById(@Param('id', ParseIntPipe) id: number) {
return await this.testsService.userGetOne(id);
}
}
+2
View File
@@ -2,9 +2,11 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Test } from './entities/test.entity';
import { TestsService } from './providers/tests.service';
import { TestsController } from './tests.controller';
@Module({
imports: [TypeOrmModule.forFeature([Test])],
controllers: [TestsController],
providers: [TestsService],
exports: [TestsService],
})
@@ -0,0 +1,34 @@
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { AccessType } from '../enums/access-types.enum';
import { I18nContext } from 'nestjs-i18n';
@ValidatorConstraint({ name: 'priceAccessType' })
export class PriceAccessTypeConstraint implements ValidatorConstraintInterface {
validate(
_: 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
const { price, accessType } = obj;
return (
(accessType === AccessType.PAID && price != null) ||
(accessType !== AccessType.PAID && price == null)
);
}
defaultMessage(): string {
const i18n = I18nContext.current();
return (
i18n?.t('tests.errors.priceAccessConstraint') ??
"Test's price depends to access type of 'Paid'."
);
}
}