diff --git a/package.json b/package.json index fab61b2..46153fd 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "pg": "^8.13.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", + "slugify": "^1.6.6", "typeorm": "^0.3.20" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index af2cea6..4c5f4cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: rxjs: specifier: ^7.8.1 version: 7.8.1 + slugify: + specifier: ^1.6.6 + version: 1.6.6 typeorm: specifier: ^0.3.20 version: 0.3.20(ioredis@5.4.2)(pg@8.13.1)(redis@4.7.0)(ts-node@10.9.2(@types/node@20.17.14)(typescript@5.7.3)) @@ -4212,6 +4215,10 @@ packages: slick@1.12.2: resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==} + slugify@1.6.6: + resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==} + engines: {node: '>=8.0.0'} + sonic-boom@4.2.0: resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} @@ -9963,6 +9970,8 @@ snapshots: slick@1.12.2: optional: true + slugify@1.6.6: {} + sonic-boom@4.2.0: dependencies: atomic-sleep: 1.0.0 diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 0322cc0..a270854 100644 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -56,11 +56,14 @@ export const enum UserMessage { EMAIL_EXIST = "ایمیل قبلا ثبت شده است", ROLE_NOT_FOUND = "نقش یافت نشد", USER_EXISTS = "کاربر با این مشخصات وجود دارد", + USERNAME_EXIST = "نام کاربری قبلا ثبت شده است", + USERNAME_NOT_EMPTY = "نام کاربری نمی‌تواند خالی باشد", + USERNAME_SHOULD_BE_BETWEEN_3_AND_50 = "نام کاربری باید بین ۳ تا ۵۰ کاراکتر باشد", } export const enum CommonMessage { VALIDITY_TYPE_REQUIRED = "نوع اعتبار سنجی مورد نیاز است", THIS_FILED_IS_REQUIRED = "این فیلد الزامی است", VALID_FOR_CHOOSE = "معتبر برای انتخاب", - USER_EXIST = "کاربر وجود دارد", + UPDATE_SUCCESS = "با موفقیت به روز رسانی شد", } diff --git a/src/modules/auth/providers/auth.service.ts b/src/modules/auth/providers/auth.service.ts index 1a653db..fce6c9e 100644 --- a/src/modules/auth/providers/auth.service.ts +++ b/src/modules/auth/providers/auth.service.ts @@ -1,7 +1,7 @@ import { BadRequestException, Injectable, Logger } from "@nestjs/common"; import { TokensService } from "./tokens.service"; -import { AuthMessage, CommonMessage, UserMessage } from "../../../common/enums/message.enum"; +import { AuthMessage, UserMessage } from "../../../common/enums/message.enum"; import { UsersService } from "../../users/providers/users.service"; import { OTPService } from "../../utils/providers/otp.service"; import { PasswordService } from "../../utils/providers/password.service"; @@ -34,7 +34,7 @@ export class AuthService { } const otpCode = await this.otpService.generateAndSetInCache(phone, "REGISTER"); // await SmsService.sendOtp(phone, otpCode); - this.logger.log(`OTP sent to ${phone}: ${otpCode}`); + this.logger.debug(`OTP sent to ${phone}: ${otpCode}`); return { message: AuthMessage.OTP_SENT, @@ -82,7 +82,7 @@ export class AuthService { async checkUserExist(checkUserExistDto: CheckUserExistDto) { const user = await this.usersService.findOneWithEmail(checkUserExistDto.email); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); - return { message: CommonMessage.USER_EXIST }; + return { message: UserMessage.USER_EXISTS }; } //****************** */ @@ -102,7 +102,7 @@ export class AuthService { const otpCode = await this.otpService.generateAndSetInCache(phone, "LOGIN"); // await SmsService.sendOtp(phone, otpCode); - this.logger.log(`OTP sent to ${phone}: ${otpCode}`); + this.logger.debug(`OTP sent to ${phone}: ${otpCode}`); return { message: AuthMessage.OTP_SENT, diff --git a/src/modules/tickets/entities/ticket-category.entity.ts b/src/modules/tickets/entities/ticket-category.entity.ts new file mode 100644 index 0000000..47e110a --- /dev/null +++ b/src/modules/tickets/entities/ticket-category.entity.ts @@ -0,0 +1,16 @@ +import { Column, Entity, OneToMany } from "typeorm"; + +import { Ticket } from "./ticket.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; + +@Entity() +export class TicketCategory extends BaseEntity { + @Column({ type: "varchar", length: 150, nullable: false, unique: true }) + name: string; + + @Column({ type: "text", nullable: true }) + description: string; + + @OneToMany(() => Ticket, (ticket) => ticket.category) + tickets: Ticket[]; +} diff --git a/src/modules/tickets/entities/ticket.entity.ts b/src/modules/tickets/entities/ticket.entity.ts new file mode 100644 index 0000000..b85e1dd --- /dev/null +++ b/src/modules/tickets/entities/ticket.entity.ts @@ -0,0 +1,36 @@ +import { Column, Entity, JoinColumn, ManyToOne } from "typeorm"; + +import { TicketCategory } from "./ticket-category.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { User } from "../../users/entities/user.entity"; +import { TicketPriority } from "../enums/ticket-priority.enum"; +import { TicketStatus } from "../enums/ticket-status.enum"; + +@Entity() +export class Ticket extends BaseEntity { + @Column({ type: "varchar", length: 150, nullable: false }) + title: string; + + @Column({ type: "varchar", length: 150, nullable: false }) + subject: string; + + @Column({ type: "enum", enum: TicketStatus, default: TicketStatus.PENDING }) + status: TicketStatus; + + @Column({ type: "enum", enum: TicketPriority, nullable: false }) + priority: TicketPriority; + + //TODO:ADD service reference here + + @JoinColumn() + @ManyToOne(() => User, (user) => user.tickets, { nullable: true }) + assignedTo: User; + + @JoinColumn() + @ManyToOne(() => User, (user) => user.tickets, { onDelete: "CASCADE", nullable: false }) + user: User; + + @JoinColumn() + @ManyToOne(() => TicketCategory, (category) => category.tickets, { onDelete: "RESTRICT", nullable: false }) + category: TicketCategory; +} diff --git a/src/modules/tickets/enums/ticket-priority.enum.ts b/src/modules/tickets/enums/ticket-priority.enum.ts new file mode 100644 index 0000000..6fae7f5 --- /dev/null +++ b/src/modules/tickets/enums/ticket-priority.enum.ts @@ -0,0 +1,5 @@ +export enum TicketPriority { + LOW = "LOW", + MEDIUM = "MEDIUM", + HIGH = "HIGH", +} diff --git a/src/modules/tickets/enums/ticket-status.enum.ts b/src/modules/tickets/enums/ticket-status.enum.ts new file mode 100644 index 0000000..000c0e2 --- /dev/null +++ b/src/modules/tickets/enums/ticket-status.enum.ts @@ -0,0 +1,5 @@ +export enum TicketStatus { + PENDING = "PENDING", + ANSWERED = "ANSWERED", + CLOSED = "CLOSED", +} diff --git a/src/modules/tickets/tickets.module.ts b/src/modules/tickets/tickets.module.ts index bf16864..5629da4 100644 --- a/src/modules/tickets/tickets.module.ts +++ b/src/modules/tickets/tickets.module.ts @@ -1,9 +1,14 @@ -import { Module } from '@nestjs/common'; -import { TicketsService } from './tickets.service'; -import { TicketsController } from './tickets.controller'; +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { TicketCategory } from "./entities/ticket-category.entity"; +import { Ticket } from "./entities/ticket.entity"; +import { TicketsController } from "./tickets.controller"; +import { TicketsService } from "./tickets.service"; @Module({ + imports: [TypeOrmModule.forFeature([Ticket, TicketCategory])], providers: [TicketsService], - controllers: [TicketsController] + controllers: [TicketsController], }) export class TicketsModule {} diff --git a/src/modules/users/DTO/check-validity.dto.ts b/src/modules/users/DTO/check-validity.dto.ts index be8ef7a..f4666e7 100644 --- a/src/modules/users/DTO/check-validity.dto.ts +++ b/src/modules/users/DTO/check-validity.dto.ts @@ -1,7 +1,7 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsEnum, IsNotEmpty, IsString } from "class-validator"; +import { IsEnum, IsNotEmpty, IsString, Length } from "class-validator"; -import { CommonMessage } from "../../../common/enums/message.enum"; +import { CommonMessage, UserMessage } from "../../../common/enums/message.enum"; import { ValidityType } from "../enums/validity-type.enum"; export class CheckValidityDTO { @@ -11,6 +11,7 @@ export class CheckValidityDTO { type: ValidityType; @IsNotEmpty({ message: CommonMessage.THIS_FILED_IS_REQUIRED }) + @Length(3, 50, { message: UserMessage.USERNAME_SHOULD_BE_BETWEEN_3_AND_50 }) @IsString({ message: CommonMessage.THIS_FILED_IS_REQUIRED }) @ApiProperty({ example: "mahyargdz" }) value: string; diff --git a/src/modules/users/DTO/update-profile.dto.ts b/src/modules/users/DTO/update-profile.dto.ts index 175b672..931b6b2 100644 --- a/src/modules/users/DTO/update-profile.dto.ts +++ b/src/modules/users/DTO/update-profile.dto.ts @@ -1,3 +1,14 @@ -export class UpdateProfileDto { - userName: string; +import { ApiProperty, PartialType, PickType } from "@nestjs/swagger"; +import { IsNotEmpty, IsOptional, IsString, Length } from "class-validator"; + +import { UserMessage } from "../../../common/enums/message.enum"; +import { CompleteRegistrationDto } from "../../auth/DTO/complete-register.dto"; + +export class UpdateProfileDto extends PartialType(PickType(CompleteRegistrationDto, ["firstName", "lastName", "birthDate"] as const)) { + @IsOptional() + @IsNotEmpty({ message: UserMessage.USERNAME_NOT_EMPTY }) + @Length(3, 50, { message: UserMessage.USERNAME_SHOULD_BE_BETWEEN_3_AND_50 }) + @IsString({ message: UserMessage.USERNAME_NOT_EMPTY }) + @ApiProperty({ description: "User name", example: "mamad24" }) + userName?: string; } diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 0717ee2..0739ab9 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -1,7 +1,8 @@ -import { Column, Entity, JoinColumn, OneToOne } from "typeorm"; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from "typeorm"; import { Role } from "./role.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; +import { Ticket } from "../../tickets/entities/ticket.entity"; @Entity() export class User extends BaseEntity { @@ -11,7 +12,7 @@ export class User extends BaseEntity { @Column({ type: "varchar", length: 11, unique: true, nullable: false }) phone: string; - @Column({ type: "varchar", length: 150, unique: true, nullable: true }) + @Column({ type: "varchar", length: 50, unique: true, nullable: true }) userName: string; @Column({ type: "varchar", length: 150 }) @@ -23,13 +24,16 @@ export class User extends BaseEntity { @Column({ type: "varchar", length: 200 }) lastName: string; - @Column() + @Column({ type: "varchar", length: 12, nullable: true }) birthDate: string; - @Column({ type: "varchar", length: 100 }) + @Column({ type: "varchar", length: 100, unique: true, nullable: false }) nationalCode: string; @JoinColumn() - @OneToOne(() => Role, { eager: true, cascade: true, onDelete: "RESTRICT", nullable: false }) + @ManyToOne(() => Role, { eager: true, onDelete: "RESTRICT", nullable: false }) role: Role; + + @OneToMany(() => Ticket, (ticket) => ticket.user) + tickets: Ticket[]; } diff --git a/src/modules/users/providers/users.repository.ts b/src/modules/users/providers/users.repository.ts index 1e7a911..29166f1 100644 --- a/src/modules/users/providers/users.repository.ts +++ b/src/modules/users/providers/users.repository.ts @@ -21,4 +21,10 @@ export class UserRepository extends Repository { phone, }); } + + async findOneWithUserName(userName: string): Promise { + return this.findOneBy({ + userName, + }); + } } diff --git a/src/modules/users/providers/users.service.ts b/src/modules/users/providers/users.service.ts index e9abbe8..599158c 100644 --- a/src/modules/users/providers/users.service.ts +++ b/src/modules/users/providers/users.service.ts @@ -1,4 +1,6 @@ import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import slugify from "slugify"; +import { Not } from "typeorm"; import { RoleRepository } from "./roles.repository"; import { UserRepository } from "./users.repository"; @@ -6,8 +8,10 @@ import { roles } from "../../../../db/seeders/role.seeder"; import { CommonMessage, UserMessage } from "../../../common/enums/message.enum"; import { CompleteRegistrationDto } from "../../auth/DTO/complete-register.dto"; import { CheckValidityDTO } from "../DTO/check-validity.dto"; +import { UpdateProfileDto } from "../DTO/update-profile.dto"; import { User } from "../entities/user.entity"; import { RoleEnum } from "../enums/role.enum"; +import { ValidityType } from "../enums/validity-type.enum"; @Injectable() export class UsersService { @@ -38,6 +42,31 @@ export class UsersService { if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); return { user }; } + + /************************************************************ */ + + async updateProfile(userId: string, updateProfileDto: UpdateProfileDto) { + const { firstName, lastName, birthDate } = updateProfileDto; + + if (updateProfileDto.userName) { + updateProfileDto.userName = slugify(updateProfileDto.userName, { lower: true, trim: true }); + const existUserName = await this.userRepository.findOneBy({ userName: updateProfileDto.userName, id: Not(userId) }); + if (existUserName) throw new BadRequestException(UserMessage.USERNAME_EXIST); + } + const user = await this.userRepository.findOneBy({ id: userId }); + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + + const { userName } = updateProfileDto; + if (userName) user.userName = userName; + if (firstName) user.firstName = firstName; + if (lastName) user.lastName = lastName; + if (birthDate) user.birthDate = birthDate; + + await this.userRepository.save(user); + return { + message: CommonMessage.UPDATE_SUCCESS, + }; + } /************************************************************ */ async findOneWithEmail(email: string): Promise { @@ -65,10 +94,13 @@ export class UsersService { } /************************************************************ */ - async checkValidity(checkDto: CheckValidityDTO) { - const { type, value } = checkDto; - const user = await this.userRepository.findOneBy({ [type]: value }); + async checkValidity(checkDto: CheckValidityDTO, userId: string) { + const { type } = checkDto; + if (type === ValidityType.USERNAME) checkDto.value = slugify(checkDto.value, { lower: true, trim: true }); + + const user = await this.userRepository.findOneBy({ [type]: checkDto.value, id: Not(userId) }); if (user) throw new BadRequestException(UserMessage.USER_EXISTS); + return { message: CommonMessage.VALID_FOR_CHOOSE }; } diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index fca1bc1..874e336 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -1,7 +1,8 @@ -import { Body, Controller, Get, Patch, Post } from "@nestjs/common"; +import { Body, Controller, Get, HttpCode, HttpStatus, Patch, Post } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { CheckValidityDTO } from "./DTO/check-validity.dto"; +import { UpdateProfileDto } from "./DTO/update-profile.dto"; import { User } from "./entities/user.entity"; import { UsersService } from "./providers/users.service"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; @@ -19,13 +20,18 @@ export class UsersController { return this.usersService.getMe(user.id); } + @AuthGuards() + @ApiOperation({ summary: "Update user profile" }) @Patch("update-profile") - updateProfile() {} + updateProfile(@Body() updateProfileDto: UpdateProfileDto, @UserDec() user: User) { + return this.usersService.updateProfile(user.id, updateProfileDto); + } @AuthGuards() @ApiOperation({ summary: "Check validity of user field" }) + @HttpCode(HttpStatus.OK) @Post("check-validity") - checkValidity(@Body() checkValidityDto: CheckValidityDTO) { - return this.usersService.checkValidity(checkValidityDto); + checkValidity(@Body() checkValidityDto: CheckValidityDTO, @UserDec() user: User) { + return this.usersService.checkValidity(checkValidityDto, user.id); } } diff --git a/tsconfig.build.json b/tsconfig.build.json index 686bf33..544225a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,8 +1,7 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "outDir": "./dist", - "incremental": true + "outDir": "./dist" }, "include": ["src"], "exclude": ["node_modules", "test", "dist", "**/*spec.ts"]