diff --git a/src/i18n/en/tests.json b/src/i18n/en/tests.json index d607be9..2802d87 100644 --- a/src/i18n/en/tests.json +++ b/src/i18n/en/tests.json @@ -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." diff --git a/src/i18n/fa/tests.json b/src/i18n/fa/tests.json index 2783ba1..705933e 100644 --- a/src/i18n/fa/tests.json +++ b/src/i18n/fa/tests.json @@ -7,7 +7,9 @@ "isActive": "فعال بودن" }, "errors": { - "intPrice": "قیمت باید عدد صحیح باشد." + "intPrice": "قیمت باید عدد صحیح باشد.", + "testNotFound": "تست مورد نظر یافت نشد.", + "priceAccessConstraint": "قیمت تست به نوع دسترسی 'پرداخت' وابسته است." }, "messages": { "createSuccess": "تست با موفقیت ایجاد شد." diff --git a/src/modules/admin/auth/dtos/admin-login.dto.ts b/src/modules/admin/auth/dtos/admin-login.dto.ts index 0e22877..3f7c376 100644 --- a/src/modules/admin/auth/dtos/admin-login.dto.ts +++ b/src/modules/admin/auth/dtos/admin-login.dto.ts @@ -19,5 +19,5 @@ export class AdminLoginDTO extends PhoneDTO { field: '$t(auth.fields.password)', }), }) - password: string; + password!: string; } diff --git a/src/modules/admin/auth/http/admin-login.post.http b/src/modules/admin/auth/http/admin-login.post.endpoints.http similarity index 79% rename from src/modules/admin/auth/http/admin-login.post.http rename to src/modules/admin/auth/http/admin-login.post.endpoints.http index 7d4ae19..febc3bd 100644 --- a/src/modules/admin/auth/http/admin-login.post.http +++ b/src/modules/admin/auth/http/admin-login.post.endpoints.http @@ -2,6 +2,6 @@ POST http://localhost:3000/admin/auth/login Content-Type: application/json { - "phone": "0933302636", + "phone": "09333026363", "password": "Password1@" } \ No newline at end of file diff --git a/src/modules/admin/tests/admin-tests.controller.ts b/src/modules/admin/tests/admin-tests.controller.ts index 1cca2c9..31a72e7 100644 --- a/src/modules/admin/tests/admin-tests.controller.ts +++ b/src/modules/admin/tests/admin-tests.controller.ts @@ -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); + } } diff --git a/src/modules/admin/tests/http/admin-tests.get.endpoints.http b/src/modules/admin/tests/http/admin-tests.get.endpoints.http new file mode 100644 index 0000000..c3129d2 --- /dev/null +++ b/src/modules/admin/tests/http/admin-tests.get.endpoints.http @@ -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 diff --git a/src/modules/admin/tests/http/admin-tests.patch.endpoints.http b/src/modules/admin/tests/http/admin-tests.patch.endpoints.http new file mode 100644 index 0000000..55c7456 --- /dev/null +++ b/src/modules/admin/tests/http/admin-tests.patch.endpoints.http @@ -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 +} diff --git a/src/modules/admin/tests/http/admin-tests.post.endpoints.http b/src/modules/admin/tests/http/admin-tests.post.endpoints.http new file mode 100644 index 0000000..5367947 --- /dev/null +++ b/src/modules/admin/tests/http/admin-tests.post.endpoints.http @@ -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 +} \ No newline at end of file diff --git a/src/modules/admin/tests/http/admin-tests.post.http b/src/modules/admin/tests/http/admin-tests.post.http deleted file mode 100644 index e275ae6..0000000 --- a/src/modules/admin/tests/http/admin-tests.post.http +++ /dev/null @@ -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 -} \ No newline at end of file diff --git a/src/modules/auth/dtos/login.dto.ts b/src/modules/auth/dtos/login.dto.ts index 07d318d..e52d54e 100644 --- a/src/modules/auth/dtos/login.dto.ts +++ b/src/modules/auth/dtos/login.dto.ts @@ -23,7 +23,7 @@ export class PhoneDTO { }), }) @Transform(({ value }) => Utils.StringUtils.normalizePhone(value as string)) - phone: string; + phone!: string; } export class LoginDTO extends PhoneDTO { diff --git a/src/modules/auth/dtos/refresh-token.dto.ts b/src/modules/auth/dtos/refresh-token.dto.ts index 48b4ddf..51416e7 100644 --- a/src/modules/auth/dtos/refresh-token.dto.ts +++ b/src/modules/auth/dtos/refresh-token.dto.ts @@ -3,5 +3,5 @@ import { IsNotEmpty, IsString } from 'class-validator'; export class RefreshTokenDTO { @IsNotEmpty() @IsString() - refreshToken: string; + refreshToken!: string; } diff --git a/src/modules/auth/dtos/verify-otp.dto.ts b/src/modules/auth/dtos/verify-otp.dto.ts index d582fe3..4b7ed06 100644 --- a/src/modules/auth/dtos/verify-otp.dto.ts +++ b/src/modules/auth/dtos/verify-otp.dto.ts @@ -9,5 +9,5 @@ export class VerifyOtpDTO extends PhoneDTO { field: '$t(auth.fields.otp)', }), }) - otp: string; + otp!: string; } diff --git a/src/modules/auth/http/login.post.endpoints.http b/src/modules/auth/http/login.post.endpoints.http index a66a9c6..a2ae2aa 100644 --- a/src/modules/auth/http/login.post.endpoints.http +++ b/src/modules/auth/http/login.post.endpoints.http @@ -3,5 +3,6 @@ Content-Type: application/json lang: fa { - "phone": "09121111111" + "phone": "09333026363", + "password": "Password1@" } \ No newline at end of file diff --git a/src/modules/auth/http/refresh.post.endpoints.http b/src/modules/auth/http/refresh.post.endpoints.http index 3c882fd..8aaefd8 100644 --- a/src/modules/auth/http/refresh.post.endpoints.http +++ b/src/modules/auth/http/refresh.post.endpoints.http @@ -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" } diff --git a/src/modules/auth/providers/refresh-tokens.provider.ts b/src/modules/auth/providers/refresh-tokens.provider.ts index 4fc660b..a2e7d25 100644 --- a/src/modules/auth/providers/refresh-tokens.provider.ts +++ b/src/modules/auth/providers/refresh-tokens.provider.ts @@ -40,38 +40,32 @@ export class RefreshTokensProvider { ) {} public async refreshTokens(refreshTokenDto: RefreshTokenDTO) { - try { - const payload = await this.jwtService.verifyAsync( - refreshTokenDto.refreshToken, - { - secret: this.jwtConfiguration.refresh.secret, - issuer: this.jwtConfiguration.issuer, - audience: this.jwtConfiguration.audience, - }, - ); + const payload = await this.jwtService.verifyAsync( + refreshTokenDto.refreshToken, + { + secret: this.jwtConfiguration.refresh.secret, + issuer: this.jwtConfiguration.issuer, + audience: this.jwtConfiguration.audience, + }, + ); - const user = await this.usersService.findOneById(payload.sub); + const user = await this.usersService.findOneById(payload.sub); - if (!payload.jti) { - throw new UnauthorizedException('auth.errors.unauthorized'); - } - - const existsInRedis = await this.authRedisProvider.hasRefreshToken( - payload.jti, - ); - - if (!existsInRedis) { - throw new UnauthorizedException('auth.errors.tokenReuse'); - } - - await this.authRedisProvider.revokeRefreshToken(payload.jti); - - const newJti = randomUUID(); - return await this.generateTokenProvider.generateTokens(user, newJti); - } catch (err) { - throw new UnauthorizedException('auth.errors.unauthorized', { - cause: err, - }); + if (!payload.jti) { + throw new UnauthorizedException('auth.errors.unauthorized'); } + + const existsInRedis = await this.authRedisProvider.hasRefreshToken( + payload.jti, + ); + + if (!existsInRedis) { + throw new UnauthorizedException('auth.errors.tokenReuse'); + } + + await this.authRedisProvider.revokeRefreshToken(payload.jti); + + const newJti = randomUUID(); + return await this.generateTokenProvider.generateTokens(user, newJti); } } diff --git a/src/modules/otp/entities/otp.entity.ts b/src/modules/otp/entities/otp.entity.ts index ecfa1a3..80162ef 100644 --- a/src/modules/otp/entities/otp.entity.ts +++ b/src/modules/otp/entities/otp.entity.ts @@ -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() { diff --git a/src/modules/questions/entities/choice.entity.ts b/src/modules/questions/entities/choice.entity.ts index f365b72..34075ce 100644 --- a/src/modules/questions/entities/choice.entity.ts +++ b/src/modules/questions/entities/choice.entity.ts @@ -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; } diff --git a/src/modules/questions/entities/question.entity.ts b/src/modules/questions/entities/question.entity.ts index acbd6ca..1123aac 100644 --- a/src/modules/questions/entities/question.entity.ts +++ b/src/modules/questions/entities/question.entity.ts @@ -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; @CreateDateColumn() - createdAt: Date; + createdAt!: Date; } diff --git a/src/modules/scoring/entities/rule-set.entity.ts b/src/modules/scoring/entities/rule-set.entity.ts index 13f4b8d..4e725b0 100644 --- a/src/modules/scoring/entities/rule-set.entity.ts +++ b/src/modules/scoring/entities/rule-set.entity.ts @@ -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; diff --git a/src/modules/submissions/entities/submission-answer.entity.ts b/src/modules/submissions/entities/submission-answer.entity.ts index 185827b..4759b18 100644 --- a/src/modules/submissions/entities/submission-answer.entity.ts +++ b/src/modules/submissions/entities/submission-answer.entity.ts @@ -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; diff --git a/src/modules/submissions/entities/submission-result.entity.ts b/src/modules/submissions/entities/submission-result.entity.ts index fcaeedc..8419b5a 100644 --- a/src/modules/submissions/entities/submission-result.entity.ts +++ b/src/modules/submissions/entities/submission-result.entity.ts @@ -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; } diff --git a/src/modules/submissions/entities/submission.entity.ts b/src/modules/submissions/entities/submission.entity.ts index 6bdd714..a0572bb 100644 --- a/src/modules/submissions/entities/submission.entity.ts +++ b/src/modules/submissions/entities/submission.entity.ts @@ -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; + answers!: Array; @CreateDateColumn() - createdAt: Date; + createdAt!: Date; } diff --git a/src/modules/tests/decorators/price-access-type.decorator.ts b/src/modules/tests/decorators/price-access-type.decorator.ts new file mode 100644 index 0000000..8b847a1 --- /dev/null +++ b/src/modules/tests/decorators/price-access-type.decorator.ts @@ -0,0 +1,6 @@ +import { Validate } from 'class-validator'; +import { PriceAccessTypeConstraint } from '../validators/price-access-type-constraint.validator'; + +export const PriceAccessType = () => { + return Validate(PriceAccessTypeConstraint); +}; diff --git a/src/modules/tests/dtos/create-test.dto.ts b/src/modules/tests/dtos/create-test.dto.ts index b024957..ea316b4 100644 --- a/src/modules/tests/dtos/create-test.dto.ts +++ b/src/modules/tests/dtos/create-test.dto.ts @@ -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; } diff --git a/src/modules/tests/dtos/update-test.dto.ts b/src/modules/tests/dtos/update-test.dto.ts new file mode 100644 index 0000000..dbc9a30 --- /dev/null +++ b/src/modules/tests/dtos/update-test.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateTestDTO } from './create-test.dto'; + +export class UpdateTestDTO extends PartialType(CreateTestDTO) {} diff --git a/src/modules/tests/entities/test.entity.ts b/src/modules/tests/entities/test.entity.ts index dcef869..653322b 100644 --- a/src/modules/tests/entities/test.entity.ts +++ b/src/modules/tests/entities/test.entity.ts @@ -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; + questions!: Array; @OneToOne(() => RuleSet, (ruleSet) => ruleSet.test) - ruleSet: RuleSet; + ruleSet!: RuleSet; @OneToMany(() => ResultRange, (range) => range.test) - resultRanges: Array; + resultRanges!: Array; @CreateDateColumn() - createdAt: Date; + createdAt!: Date; } diff --git a/src/modules/tests/http/tests.get.endpoints.http b/src/modules/tests/http/tests.get.endpoints.http new file mode 100644 index 0000000..71cbcaf --- /dev/null +++ b/src/modules/tests/http/tests.get.endpoints.http @@ -0,0 +1,3 @@ +GET http://localhost:3000/tests + +GET http://localhost:3000/tests/4 diff --git a/src/modules/tests/providers/tests.service.ts b/src/modules/tests/providers/tests.service.ts index 38e8ce3..7da84eb 100644 --- a/src/modules/tests/providers/tests.service.ts +++ b/src/modules/tests/providers/tests.service.ts @@ -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', + }); + } + } } diff --git a/src/modules/tests/tests.controller.ts b/src/modules/tests/tests.controller.ts new file mode 100644 index 0000000..c7c9e65 --- /dev/null +++ b/src/modules/tests/tests.controller.ts @@ -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); + } +} diff --git a/src/modules/tests/tests.module.ts b/src/modules/tests/tests.module.ts index 881eff5..af066df 100644 --- a/src/modules/tests/tests.module.ts +++ b/src/modules/tests/tests.module.ts @@ -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], }) diff --git a/src/modules/tests/validators/price-access-type-constraint.validator.ts b/src/modules/tests/validators/price-access-type-constraint.validator.ts new file mode 100644 index 0000000..69a9812 --- /dev/null +++ b/src/modules/tests/validators/price-access-type-constraint.validator.ts @@ -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 { + 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'." + ); + } +}