From 87d7b4e2934115a9a474da6ebae914e297c90fad Mon Sep 17 00:00:00 2001 From: MinhhTien <92145479+MinhhTien@users.noreply.github.com> Date: Thu, 25 Jan 2024 03:28:18 +0700 Subject: [PATCH] EF-81-83 Customer can view and add to cart --- src/app.module.ts | 8 ++- src/auth/controllers/auth.controller.ts | 6 +- src/auth/dto/token.dto.ts | 5 ++ src/auth/strategies/jwt-access.strategy.ts | 2 +- src/auth/strategies/jwt-refresh.strategy.ts | 2 +- src/cart/cart.module.ts | 15 ++++ src/cart/controllers/cart.controller.ts | 36 ++++++++++ src/cart/dto/cart.dto.ts | 11 +++ src/cart/repositories/cart.repository.ts | 13 ++++ src/cart/schemas/cart.schema.ts | 67 +++++++++++++++++ src/cart/services/cart.service.ts | 71 +++++++++++++++++++ src/common/contracts/dto.ts | 19 ++++- src/common/decorators/pagination.decorator.ts | 56 +++++++++++++++ .../controllers/customer.controller.ts | 24 +++---- src/customer/schemas/customer.schema.ts | 3 +- src/product/product.module.ts | 12 ++++ src/product/schemas/product.schema.ts | 2 - tsconfig.json | 2 + 18 files changed, 330 insertions(+), 24 deletions(-) create mode 100644 src/cart/controllers/cart.controller.ts create mode 100644 src/cart/dto/cart.dto.ts create mode 100644 src/cart/repositories/cart.repository.ts create mode 100644 src/cart/services/cart.service.ts create mode 100644 src/common/decorators/pagination.decorator.ts diff --git a/src/app.module.ts b/src/app.module.ts index 6839aaf..7c6bfb6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,7 +10,9 @@ import { join } from 'path' import configuration from '@src/config' import { CommonModule } from '@common/common.module' import { CustomerModule } from '@customer/customer.module' -import { AuthModule } from './auth/auth.module' +import { AuthModule } from '@auth/auth.module' +import { ProductModule } from '@product/product.module' +import { CartModule } from '@cart/cart.module' @Module({ imports: [ @@ -53,7 +55,9 @@ import { AuthModule } from './auth/auth.module' ]), CommonModule, CustomerModule, - AuthModule + AuthModule, + ProductModule, + CartModule ], controllers: [AppController], providers: [AppService] diff --git a/src/auth/controllers/auth.controller.ts b/src/auth/controllers/auth.controller.ts index 813e001..a82aa0e 100644 --- a/src/auth/controllers/auth.controller.ts +++ b/src/auth/controllers/auth.controller.ts @@ -3,7 +3,7 @@ import { ApiBadRequestResponse, ApiBearerAuth, ApiBody, ApiOkResponse, ApiTags } import { ErrorResponse } from '@common/contracts/dto' import { LoginReqDto } from '@auth/dto/login.dto' import { AuthService } from '@auth/services/auth.service' -import { TokenResDto } from '@auth/dto/token.dto' +import { ResponseTokenDto, TokenResDto } from '@auth/dto/token.dto' import { UserSide } from '@common/contracts/constant' import { JwtAuthGuard } from '@auth/guards/jwt-auth.guard' @@ -14,7 +14,7 @@ export class AuthController { @Post('customer/login') @ApiBody({ type: LoginReqDto }) - @ApiOkResponse({ type: TokenResDto }) + @ApiOkResponse({ type: ResponseTokenDto }) @ApiBadRequestResponse({ type: ErrorResponse }) async login(@Body() loginReqDto: LoginReqDto): Promise { const res = await this.authService.login(loginReqDto, UserSide.CUSTOMER) @@ -25,7 +25,7 @@ export class AuthController { @UseGuards(JwtAuthGuard.REFRESH_TOKEN) @Post('customer/refresh') @ApiBearerAuth() - @ApiOkResponse({ type: TokenResDto }) + @ApiOkResponse({ type: ResponseTokenDto }) @ApiBadRequestResponse({ type: ErrorResponse }) async refreshToken(@Req() req): Promise { const res = await this.authService.refreshAccessToken(req.user.id, UserSide.CUSTOMER) diff --git a/src/auth/dto/token.dto.ts b/src/auth/dto/token.dto.ts index 1d1048a..0717b1c 100644 --- a/src/auth/dto/token.dto.ts +++ b/src/auth/dto/token.dto.ts @@ -9,6 +9,11 @@ export class TokenResDto { refreshToken: string } +export class ResponseTokenDto { + @ApiProperty() + data: TokenResDto +} + export class RefreshTokenReqDto { @ApiProperty() @IsNotEmpty() diff --git a/src/auth/strategies/jwt-access.strategy.ts b/src/auth/strategies/jwt-access.strategy.ts index ee98b1b..88b4ee9 100644 --- a/src/auth/strategies/jwt-access.strategy.ts +++ b/src/auth/strategies/jwt-access.strategy.ts @@ -16,7 +16,7 @@ export class JwtAccessStrategy extends PassportStrategy(Strategy, 'jwt-access') } async validate(payload: AccessTokenPayload) { - return { id: payload.sub, name: payload.name, role: payload.role } + return { _id: payload.sub, name: payload.name, role: payload.role } } } diff --git a/src/auth/strategies/jwt-refresh.strategy.ts b/src/auth/strategies/jwt-refresh.strategy.ts index faa251f..a4dc102 100644 --- a/src/auth/strategies/jwt-refresh.strategy.ts +++ b/src/auth/strategies/jwt-refresh.strategy.ts @@ -16,7 +16,7 @@ export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh' } async validate(payload: RefreshTokenPayload) { - return { id: payload.sub, role: payload.role } + return { _id: payload.sub, role: payload.role } } } diff --git a/src/cart/cart.module.ts b/src/cart/cart.module.ts index e69de29..d1fcd47 100644 --- a/src/cart/cart.module.ts +++ b/src/cart/cart.module.ts @@ -0,0 +1,15 @@ +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' + +import { Cart, CartSchema } from '@cart/schemas/cart.schema' +import { CartController } from '@cart/controllers/cart.controller' +import { CartService } from '@cart/services/cart.service' +import { CartRepository } from '@cart/repositories/cart.repository' + +@Module({ + imports: [MongooseModule.forFeature([{ name: Cart.name, schema: CartSchema }])], + controllers: [CartController], + providers: [CartService, CartRepository], + exports: [CartService] +}) +export class CartModule {} diff --git a/src/cart/controllers/cart.controller.ts b/src/cart/controllers/cart.controller.ts new file mode 100644 index 0000000..97815e0 --- /dev/null +++ b/src/cart/controllers/cart.controller.ts @@ -0,0 +1,36 @@ +import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common' +import { ApiBadRequestResponse, ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger' +import * as _ from 'lodash' + +import { ErrorResponse, ResponseSuccessDto } from '@common/contracts/dto' +import { CartService } from '@cart/services/cart.service' +import { JwtAuthGuard } from '@auth/guards/jwt-auth.guard' +import { RolesGuard } from '@auth/guards/roles.guard' +import { UserRole } from '@common/contracts/constant' +import { Roles } from '@auth/decorators/roles.decorator' +import { AddToCartDto, ResponseCartDto } from '@cart/dto/cart.dto' + +@ApiTags('Cart') +@ApiBearerAuth() +@Roles(UserRole.CUSTOMER) +@UseGuards(JwtAuthGuard.ACCESS_TOKEN, RolesGuard) +@Controller('cart') +export class CartController { + constructor(private readonly cartService: CartService) {} + + @Post() + @ApiOkResponse({ type: ResponseSuccessDto }) + @ApiBadRequestResponse({ type: ErrorResponse }) + async addToCart(@Req() req, @Body() addToCartDto: AddToCartDto) { + addToCartDto.customerId = _.get(req, 'user._id') + const customer = await this.cartService.addToCart(addToCartDto) + return customer + } + + @Get() + @ApiOkResponse({ type: ResponseCartDto }) + async getListCard(@Req() req) { + const customerId = _.get(req, 'user._id') + return await this.cartService.getListCard(customerId) + } +} diff --git a/src/cart/dto/cart.dto.ts b/src/cart/dto/cart.dto.ts new file mode 100644 index 0000000..60bdc11 --- /dev/null +++ b/src/cart/dto/cart.dto.ts @@ -0,0 +1,11 @@ +import { Cart, ItemDto } from '@cart/schemas/cart.schema' +import { ApiProperty } from '@nestjs/swagger' + +export class AddToCartDto extends ItemDto { + customerId?: string +} + +export class ResponseCartDto { + @ApiProperty() + data: Cart +} \ No newline at end of file diff --git a/src/cart/repositories/cart.repository.ts b/src/cart/repositories/cart.repository.ts new file mode 100644 index 0000000..11911c2 --- /dev/null +++ b/src/cart/repositories/cart.repository.ts @@ -0,0 +1,13 @@ +import { PaginateModel } from 'mongoose' +import { Injectable } from '@nestjs/common' +import { InjectModel } from '@nestjs/mongoose' + +import { AbstractRepository } from '@common/repositories' +import { Cart, CartDocument } from '@cart/schemas/cart.schema' + +@Injectable() +export class CartRepository extends AbstractRepository { + constructor(@InjectModel(Cart.name) model: PaginateModel) { + super(model) + } +} diff --git a/src/cart/schemas/cart.schema.ts b/src/cart/schemas/cart.schema.ts index e69de29..016a8c9 100644 --- a/src/cart/schemas/cart.schema.ts +++ b/src/cart/schemas/cart.schema.ts @@ -0,0 +1,67 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose' +import { HydratedDocument } from 'mongoose' +import * as paginate from 'mongoose-paginate-v2' +import { ApiProperty } from '@nestjs/swagger' +import { Transform, Type } from 'class-transformer' +import { Status } from '@common/contracts/constant' +import { IsNotEmpty, Min, ValidateNested } from 'class-validator' + +import { Product } from '@product/schemas/product.schema' + +export class ItemDto { + @ApiProperty() + @IsNotEmpty() + @ValidateNested() + product: Product + + @ApiProperty() + @IsNotEmpty() + @Min(1) + quantity: number +} + +export type CartDocument = HydratedDocument + +@Schema({ + collection: 'carts', + timestamps: { + createdAt: true, + updatedAt: true + }, + toJSON: { + transform(doc, ret) { + delete ret.__v + } + } +}) +export class Cart { + constructor(id?: string) { + this._id = id + } + + @ApiProperty() + @Transform(({ value }) => value?.toString()) + _id: string + + @Prop({ type: String, required: true }) + customerId: string; + + @ApiProperty({ isArray: true, type: ItemDto }) + @ValidateNested() + @Prop({ type: Array, required: true }) + items: ItemDto[] + + @ApiProperty() + @Prop({ type: Number, required: true }) + totalAmount: number + + @Prop({ + enum: Status, + default: Status.ACTIVE + }) + status: Status +} + +export const CartSchema = SchemaFactory.createForClass(Cart) + +CartSchema.plugin(paginate) diff --git a/src/cart/services/cart.service.ts b/src/cart/services/cart.service.ts new file mode 100644 index 0000000..2b5d1ac --- /dev/null +++ b/src/cart/services/cart.service.ts @@ -0,0 +1,71 @@ +import { BadRequestException, Injectable } from '@nestjs/common' +import { Errors } from '@src/common/contracts/error' +import { CartRepository } from '@cart/repositories/cart.repository' +import { AddToCartDto } from '@cart/dto/cart.dto' + +@Injectable() +export class CartService { + constructor(private readonly cartRepository: CartRepository) {} + + public async addToCart(addToCartDto: AddToCartDto) { + const { customerId, product, quantity } = addToCartDto + const cart = await this.cartRepository.findOne({ + conditions: { + customerId + } + }) + const amount = product.price * quantity + if (!cart) { + // create new cart + await this.cartRepository.create({ + customerId, + items: [{ product, quantity }], + totalAmount: amount + }) + } else { + const { _id, items } = cart + // update cart + // check existed item + const existedItemIndex = items.findIndex((item) => item.product._id === product._id) + + if (existedItemIndex === -1) { + // push new item in the first element + items.unshift({ product, quantity }) + } else { + // update quantity in existed item + items[existedItemIndex].quantity += quantity + } + const totalAmount = (cart.totalAmount += amount) + await this.cartRepository.findOneAndUpdate( + { + _id + }, + { + items, + totalAmount + } + ) + } + return { success: true } + } + + public async getListCard(customerId: string) { + const cartList = await this.cartRepository.findOne({ + conditions: { customerId }, + projection: { + _id: 1, + items: 1, + totalAmount: 1 + } + }) + if (!cartList) { + const newCartList = await this.cartRepository.create({ + customerId, + items: [], + totalAmount: 0 + }) + return { _id: newCartList._id, items: [], totalAmount: 0 } + } + return cartList + } +} diff --git a/src/common/contracts/dto.ts b/src/common/contracts/dto.ts index 40bf82b..0351f0a 100644 --- a/src/common/contracts/dto.ts +++ b/src/common/contracts/dto.ts @@ -1,4 +1,21 @@ -import { ApiProperty } from '@nestjs/swagger' +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { IsPositive } from 'class-validator'; + +export class PaginateDto { + @ApiProperty({ type: Number }) + @ApiPropertyOptional() + @IsPositive() + page = 1; + + @ApiProperty({ type: Number }) + @ApiPropertyOptional() + @IsPositive() + limit = 10; + + @ApiProperty({ type: String, example: 'field1.asc_field2.desc' }) + @ApiPropertyOptional() + sort: Record; +} export class BooleanResponseDto { @ApiProperty() diff --git a/src/common/decorators/pagination.decorator.ts b/src/common/decorators/pagination.decorator.ts new file mode 100644 index 0000000..bdff069 --- /dev/null +++ b/src/common/decorators/pagination.decorator.ts @@ -0,0 +1,56 @@ +import { ExecutionContext, createParamDecorator } from '@nestjs/common' +import * as _ from 'lodash' + +export interface PaginationParams { + page: number + limit: number + sort: Record +} + +const DEFAULT_LIMIT = 10 + +export const handlePagination: (request: any) => PaginationParams = (request) => { + const paginationParams = { + page: request.query.page, + limit: request.query.limit, + sort: request.query.sort + } + paginationParams.page = Number(paginationParams.page) > 0 ? Number(paginationParams.page) : 1 + paginationParams.limit = Number(paginationParams.limit) > 0 ? Number(paginationParams.limit) : DEFAULT_LIMIT + if (_.isEmpty(paginationParams.sort)) { + paginationParams.sort = { + createdAt: -1 + } + } else { + const result = {} + const sortFields = paginationParams.sort.split('_') + sortFields.forEach((item) => { + const sortType = item.indexOf('.asc') !== -1 ? '.asc' : '.desc' + result[item.replace(sortType, '')] = sortType === '.asc' ? 1 : -1 + }) + paginationParams.sort = result + } + return paginationParams +} + +/** + * How to use + - In controller: + @ApiQuery({ type: PaginationQuery }) + @Get() + async list( + @Pagination() paginationParams: PaginationParams, + @Query() filterDto: FilterDto, + ) { + return await this.service.list(filterDto, paginationParams); + } + + - In service: + async list(filterDto, paginationParams) { + return await this.service.list(filterDto, paginationParams); + } + */ +export const Pagination = createParamDecorator((data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest() + return handlePagination(request) +}) diff --git a/src/customer/controllers/customer.controller.ts b/src/customer/controllers/customer.controller.ts index b9f4e26..69c0f75 100644 --- a/src/customer/controllers/customer.controller.ts +++ b/src/customer/controllers/customer.controller.ts @@ -11,21 +11,21 @@ import { ErrorResponse } from '@common/contracts/dto' export class CustomerController { constructor(private readonly customerService: CustomerService) {} - @Post('register') - @ApiOkResponse({ type: Customer }) - async login(@Body() createCustomerDto: CreateCustomerDto) { - const customer = await this.customerService.createCustomer(createCustomerDto) - return customer - } + // @Post('register') + // @ApiOkResponse({ type: Customer }) + // async login(@Body() createCustomerDto: CreateCustomerDto) { + // const customer = await this.customerService.createCustomer(createCustomerDto) + // return customer + // } - @Get(':id') - @ApiBadRequestResponse({ type: ErrorResponse }) - @ApiOkResponse({ type: Customer }) + // @Get(':id') + // @ApiBadRequestResponse({ type: ErrorResponse }) + // @ApiOkResponse({ type: Customer }) // @ApiBearerAuth() // @UseGuards(SidesGuard) // @UseGuards(AccessTokenGuard) // @Sides(SidesAuth.CUSTOMER) - async getDetail(@Req() req, @Param('id') id: string) { - return await this.customerService.getCustomerDetail(id) - } + // async getDetail(@Req() req, @Param('id') id: string) { + // return await this.customerService.getCustomerDetail(id) + // } } diff --git a/src/customer/schemas/customer.schema.ts b/src/customer/schemas/customer.schema.ts index 6972650..f42b97b 100644 --- a/src/customer/schemas/customer.schema.ts +++ b/src/customer/schemas/customer.schema.ts @@ -70,7 +70,7 @@ export class Customer { dateOfBirth: Date @ApiProperty() - @Prop({ type: String, enum: Gender, default: Gender.OTHER }) + @Prop({ enum: Gender, default: Gender.OTHER }) gender: Gender @ApiProperty() @@ -94,7 +94,6 @@ export class Customer { lastLoginDate: Date @Prop({ - type: String, enum: Status, default: Status.ACTIVE }) diff --git a/src/product/product.module.ts b/src/product/product.module.ts index e69de29..460af2c 100644 --- a/src/product/product.module.ts +++ b/src/product/product.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common' +import { MongooseModule } from '@nestjs/mongoose' + +import { Product, ProductSchema } from '@product/schemas/product.schema' + +@Module({ + imports: [MongooseModule.forFeature([{ name: Product.name, schema: ProductSchema }])], + controllers: [], + providers: [], + exports: [], +}) +export class ProductModule {} diff --git a/src/product/schemas/product.schema.ts b/src/product/schemas/product.schema.ts index e7d6bf9..93801a1 100644 --- a/src/product/schemas/product.schema.ts +++ b/src/product/schemas/product.schema.ts @@ -123,9 +123,7 @@ export class Product { variants: Variant[] //TODO: Add another status and fix this props - @ApiProperty() @Prop({ - type: String, enum: Status, default: Status.ACTIVE }) diff --git a/tsconfig.json b/tsconfig.json index 7e2185b..16a0733 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,6 +27,8 @@ "@seeds/*": ["src/seeds/*"], "@i18n/*": ["src/i18n/*"], "@booking/*": ["src/booking/*"], + "@product/*": ["src/product/*"], + "@cart/*": ["src/cart/*"], } } }