diff --git a/src/app.module.ts b/src/app.module.ts index 675714f..aca4ef6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; import { CommonModule } from './common/common.module'; +import { UserModule } from './user/user.module'; @Module({ - imports: [CommonModule], + imports: [CommonModule, UserModule], controllers: [], providers: [], }) diff --git a/src/common/auth.decorator.ts b/src/common/auth.decorator.ts new file mode 100644 index 0000000..6d828ea --- /dev/null +++ b/src/common/auth.decorator.ts @@ -0,0 +1,17 @@ +import { + createParamDecorator, + ExecutionContext, + HttpException, +} from '@nestjs/common'; + +export const Auth = createParamDecorator( + (data: unknown, context: ExecutionContext) => { + const request = context.switchToHttp().getRequest(); + const user = request.user; + if (user) { + return user; + } else { + throw new HttpException('Unauthorized', 401); + } + }, +); diff --git a/src/main.ts b/src/main.ts index cd232fc..851983d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,7 @@ async function bootstrap() { const app = await NestFactory.create(AppModule); const logger = app.get(WINSTON_MODULE_NEST_PROVIDER); - app.use(logger); + app.useLogger(logger); await app.listen(3000); } diff --git a/src/model/user.model.ts b/src/model/user.model.ts new file mode 100644 index 0000000..6eca806 --- /dev/null +++ b/src/model/user.model.ts @@ -0,0 +1,27 @@ +export class RegisterUserRequest { + username: string; + password: string; + first_name: string; + last_name?: string; + email?: string; + phone?: string; +} + +export class UserResponse { + username: string; + first_name: string; + last_name?: string; + email?: string; + phone?: string; + token?: string; +} + +export class LoginUserRequest { + username: string; + password: string; +} + +export class UpdateUserRequest { + first_name?: string; + password?: string; +} diff --git a/src/model/web.model.ts b/src/model/web.model.ts new file mode 100644 index 0000000..c205b79 --- /dev/null +++ b/src/model/web.model.ts @@ -0,0 +1,11 @@ +export class WebResponse { + data?: T; + errors?: string; + paging?: Paging; +} + +export class Paging { + size: number; + total_page: number; + current_page: number; +} diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts new file mode 100644 index 0000000..6a3fdae --- /dev/null +++ b/src/user/user.controller.ts @@ -0,0 +1,76 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Patch, + Post, +} from '@nestjs/common'; +import { UserService } from './user.service'; +import { WebResponse } from '../model/web.model'; +import { + LoginUserRequest, + RegisterUserRequest, + UpdateUserRequest, + UserResponse, +} from '../model/user.model'; +import { Auth } from '../common/auth.decorator'; +import { User } from '@prisma/client'; + +@Controller('/api/users') +export class UserController { + constructor(private userService: UserService) {} + + @Post() + @HttpCode(200) + async register( + @Body() request: RegisterUserRequest, + ): Promise> { + const result = await this.userService.register(request); + return { + data: result, + }; + } + + @Post('/login') + @HttpCode(200) + async login( + @Body() request: LoginUserRequest, + ): Promise> { + const result = await this.userService.login(request); + return { + data: result, + }; + } + + @Get('/current') + @HttpCode(200) + async get(@Auth() user: User): Promise> { + const result = await this.userService.get(user); + return { + data: result, + }; + } + + @Patch('/current') + @HttpCode(200) + async update( + @Auth() user: User, + @Body() request: UpdateUserRequest, + ): Promise> { + const result = await this.userService.update(user, request); + return { + data: result, + }; + } + + @Delete('/current') + @HttpCode(200) + async logout(@Auth() user: User): Promise> { + await this.userService.logout(user); + return { + data: true, + }; + } +} diff --git a/src/user/user.module.ts b/src/user/user.module.ts new file mode 100644 index 0000000..569d732 --- /dev/null +++ b/src/user/user.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { UserService } from './user.service'; +import { UserController } from './user.controller'; + +@Module({ + providers: [UserService], + controllers: [UserController], +}) +export class UserModule {} diff --git a/src/user/user.service.ts b/src/user/user.service.ts new file mode 100644 index 0000000..c51299e --- /dev/null +++ b/src/user/user.service.ts @@ -0,0 +1,147 @@ +import { HttpException, Inject, Injectable } from '@nestjs/common'; +import { + RegisterUserRequest, + UserResponse, + LoginUserRequest, + UpdateUserRequest, +} from 'src/model/user.model'; +import { ValidationService } from 'src/common/validation.service'; +import { WINSTON_MODULE_PROVIDER } from 'nest-winston'; +import { Logger } from 'winston'; +import { PrismaService } from 'src/common/prisma.service'; +import { UserValidation } from './user.validation'; +import * as bcrypt from 'bcrypt'; +import { v4 as uuid } from 'uuid'; +import { User } from '@prisma/client'; + +@Injectable() +export class UserService { + constructor( + private validationService: ValidationService, + @Inject(WINSTON_MODULE_PROVIDER) private logger: Logger, + private prismaService: PrismaService, + ) {} + + async register(request: RegisterUserRequest): Promise { + this.logger.info(`Register new yser ${JSON.stringify(request)}`); + const registerRequest: RegisterUserRequest = + this.validationService.validate(UserValidation.REGISTER, request); + + const totalUserWithSameUsername = await this.prismaService.user.count({ + where: { + username: registerRequest.username, + }, + }); + + if (totalUserWithSameUsername != 0) { + throw new HttpException('Username already exists', 400); + } + + registerRequest.password = await bcrypt.hash(registerRequest.password, 10); + + const user = await this.prismaService.user.create({ + data: registerRequest, + }); + + return { + username: user.username, + first_name: user.first_name, + }; + } + + async login(request: LoginUserRequest): Promise { + this.logger.debug(`UserService.login(${JSON.stringify(request)})`); + const loginRequest: LoginUserRequest = this.validationService.validate( + UserValidation.LOGIN, + request, + ); + + let user = await this.prismaService.user.findUnique({ + where: { + username: loginRequest.username, + }, + }); + + if (!user) { + throw new HttpException('Username or password is invalid', 401); + } + + const isPasswordValid = await bcrypt.compare( + loginRequest.password, + user.password, + ); + + if (!isPasswordValid) { + throw new HttpException('Username or password is invalid', 401); + } + + user = await this.prismaService.user.update({ + where: { + username: loginRequest.username, + }, + data: { + token: uuid(), + }, + }); + + return { + username: user.username, + first_name: user.first_name, + token: user.token, + }; + } + + async get(user: User): Promise { + return { + username: user.username, + first_name: user.first_name, + }; + } + + async update(user: User, request: UpdateUserRequest): Promise { + this.logger.debug( + `UserService.update( ${JSON.stringify(user)} , ${JSON.stringify(request)} )`, + ); + + const updateRequest: UpdateUserRequest = this.validationService.validate( + UserValidation.UPDATE, + request, + ); + + if (updateRequest.first_name) { + user.first_name = updateRequest.first_name; + } + + if (updateRequest.password) { + user.password = await bcrypt.hash(updateRequest.password, 10); + } + + const result = await this.prismaService.user.update({ + where: { + username: user.username, + }, + data: user, + }); + + return { + first_name: result.first_name, + username: result.username, + }; + } + + async logout(user: User): Promise { + const result = await this.prismaService.user.update({ + where: { + username: user.username, + }, + data: { + token: null, + }, + }); + + return { + username: result.username, + first_name: result.first_name, + }; + } +} diff --git a/src/user/user.validation.ts b/src/user/user.validation.ts new file mode 100644 index 0000000..f9b1a7f --- /dev/null +++ b/src/user/user.validation.ts @@ -0,0 +1,26 @@ +import { z, ZodType } from 'zod'; + +export class UserValidation { + static readonly REGISTER: ZodType = z.object({ + username: z.string().min(1).max(100), + password: z.string().min(1).max(100), + first_name: z.string().min(1).max(100), + last_name: z.string().min(1).max(100).optional(), + email: z.string().min(1).max(100).optional(), + phone: z.string().min(1).max(100).optional(), + }); + + static readonly LOGIN: ZodType = z.object({ + username: z.string().min(1).max(100), + password: z.string().min(1).max(100), + }); + + static readonly UPDATE: ZodType = z.object({ + first_name: z.string().min(1).max(100).optional(), + last_name: z.string().min(1).max(100).optional(), + email: z.string().min(1).max(100).optional(), + phone: z.string().min(1).max(100).optional(), + name: z.string().min(1).max(100).optional(), + password: z.string().min(1).max(100).optional(), + }); +}