From c24775d0917d2ffda19b7447d7619abf3fcd5b3b Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Tue, 18 Nov 2025 12:55:37 +0330 Subject: [PATCH] cart --- src/app.module.ts | 2 + src/modules/cart/cart.module.ts | 22 + .../cart/controllers/cart.controller.ts | 63 +++ src/modules/cart/dto/create-cart.dto.ts | 29 ++ src/modules/cart/dto/update-cart.dto.ts | 9 + src/modules/cart/providers/cart.service.ts | 413 ++++++++++++++++++ 6 files changed, 538 insertions(+) create mode 100644 src/modules/cart/cart.module.ts create mode 100644 src/modules/cart/controllers/cart.controller.ts create mode 100644 src/modules/cart/dto/create-cart.dto.ts create mode 100644 src/modules/cart/dto/update-cart.dto.ts create mode 100644 src/modules/cart/providers/cart.service.ts diff --git a/src/app.module.ts b/src/app.module.ts index 278aca0..b792bc6 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -16,6 +16,7 @@ import { AdminModule } from './modules/admin/admin.module'; import { ThrottlerModule } from '@nestjs/throttler'; import { RestaurantsModule } from './modules/restaurants/restaurants.module'; import { FoodModule } from './modules/foods/food.module'; +import { CartModule } from './modules/cart/cart.module'; @Module({ imports: [ @@ -45,6 +46,7 @@ import { FoodModule } from './modules/foods/food.module'; ]), RestaurantsModule, FoodModule, + CartModule, ], controllers: [], providers: [], diff --git a/src/modules/cart/cart.module.ts b/src/modules/cart/cart.module.ts new file mode 100644 index 0000000..34ae69a --- /dev/null +++ b/src/modules/cart/cart.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { CartService } from './providers/cart.service'; +import { CartController } from './controllers/cart.controller'; +import { Food } from '../foods/entities/food.entity'; +import { Restaurant } from '../restaurants/entities/restaurant.entity'; +import { AuthModule } from '../auth/auth.module'; +import { JwtModule } from '@nestjs/jwt'; +import { UtilsModule } from '../utils/utils.module'; + +@Module({ + imports: [ + MikroOrmModule.forFeature([Food, Restaurant]), + AuthModule, + JwtModule, + UtilsModule, + ], + controllers: [CartController], + providers: [CartService], + exports: [CartService], +}) +export class CartModule {} diff --git a/src/modules/cart/controllers/cart.controller.ts b/src/modules/cart/controllers/cart.controller.ts new file mode 100644 index 0000000..b887a90 --- /dev/null +++ b/src/modules/cart/controllers/cart.controller.ts @@ -0,0 +1,63 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common'; +import { CartService } from '../providers/cart.service'; +import { CreateCartDto } from '../dto/create-cart.dto'; +import { UpdateCartDto } from '../dto/update-cart.dto'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger'; +import { AuthGuard } from '../../auth/guards/auth.guard'; +import { UserId } from 'src/common/decorators/user-id.decorator'; +import { RestId } from 'src/common/decorators/rest-id.decorator'; + +@UseGuards(AuthGuard) +@ApiBearerAuth() +@ApiTags('cart') +@Controller('cart') +export class CartController { + constructor(private readonly cartService: CartService) {} + + @Post() + @ApiOperation({ summary: 'Create or update user cart' }) + @ApiBody({ type: CreateCartDto }) + @ApiResponse({ status: 201, description: 'Cart created successfully' }) + async create(@UserId() userId: string, @RestId() restaurantId: string, @Body() createCartDto: CreateCartDto) { + return this.cartService.create(userId, restaurantId, createCartDto); + } + + @Get() + @ApiOperation({ summary: 'Get all active carts for the current user' }) + @ApiResponse({ status: 200, description: 'Carts retrieved successfully' }) + async findAll(@UserId() userId: string) { + return this.cartService.findByUser(userId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a specific cart by ID' }) + @ApiResponse({ status: 200, description: 'Cart retrieved successfully' }) + @ApiResponse({ status: 404, description: 'Cart not found' }) + async findOne(@Param('id') id: string, @UserId() userId: string) { + return this.cartService.findOne(id, userId); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a cart' }) + @ApiBody({ type: UpdateCartDto }) + @ApiResponse({ status: 200, description: 'Cart updated successfully' }) + @ApiResponse({ status: 404, description: 'Cart not found' }) + async update(@Param('id') id: string, @Body() updateCartDto: UpdateCartDto, @UserId() userId: string) { + return this.cartService.update(id, userId, updateCartDto); + } + + @Delete('clear') + @ApiOperation({ summary: 'Clear all active carts for the current user' }) + @ApiResponse({ status: 200, description: 'All carts cleared successfully' }) + async clearAll(@UserId() userId: string) { + return this.cartService.clearUserCart(userId); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a cart (soft delete)' }) + @ApiResponse({ status: 200, description: 'Cart deleted successfully' }) + @ApiResponse({ status: 404, description: 'Cart not found' }) + async remove(@Param('id') id: string, @UserId() userId: string) { + return this.cartService.remove(id, userId); + } +} diff --git a/src/modules/cart/dto/create-cart.dto.ts b/src/modules/cart/dto/create-cart.dto.ts new file mode 100644 index 0000000..7f90ff0 --- /dev/null +++ b/src/modules/cart/dto/create-cart.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsArray, ValidateNested, IsNumber, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CartItemDto { + @ApiProperty({ description: 'Food ID', example: '01KA9Q52JSWFY98TW3HZY6XEVZ' }) + @IsNotEmpty() + @IsString() + foodId!: string; + + @ApiProperty({ description: 'Quantity of the food item', example: 2, minimum: 1 }) + @IsNotEmpty() + @IsNumber() + @Min(1) + quantity!: number; +} + +export class CreateCartDto { + @ApiProperty({ + description: 'List of cart items', + type: [CartItemDto], + example: [{ foodId: '01KA9Q52JSWFY98TW3HZY6XEVZ', quantity: 2 }], + }) + @IsNotEmpty() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CartItemDto) + items!: CartItemDto[]; +} diff --git a/src/modules/cart/dto/update-cart.dto.ts b/src/modules/cart/dto/update-cart.dto.ts new file mode 100644 index 0000000..b70b562 --- /dev/null +++ b/src/modules/cart/dto/update-cart.dto.ts @@ -0,0 +1,9 @@ +import { PartialType, ApiProperty } from '@nestjs/swagger'; +import { CreateCartDto, CartItemDto } from './create-cart.dto'; + +export class UpdateCartItemDto extends PartialType(CartItemDto) {} + +export class UpdateCartDto { + @ApiProperty({ description: 'List of cart items to update', type: [UpdateCartItemDto], required: false }) + items?: UpdateCartItemDto[]; +} diff --git a/src/modules/cart/providers/cart.service.ts b/src/modules/cart/providers/cart.service.ts new file mode 100644 index 0000000..1043d28 --- /dev/null +++ b/src/modules/cart/providers/cart.service.ts @@ -0,0 +1,413 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { Food } from '../../foods/entities/food.entity'; +import { Restaurant } from '../../restaurants/entities/restaurant.entity'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { CreateCartDto } from '../dto/create-cart.dto'; +import { UpdateCartDto } from '../dto/update-cart.dto'; +import { CacheService } from '../../utils/cache.service'; +import { randomUUID } from 'crypto'; + +export interface CartItem { + foodId: string; + foodTitle?: string; + quantity: number; + price: number; + discount: number; + totalPrice: number; +} + +export interface Cart { + id: string; + userId: string; + restaurantId: string; + restaurantName?: string; + items: CartItem[]; + totalPrice: number; + totalDiscount: number; + finalPrice: number; + totalItems: number; + createdAt: string; + updatedAt: string; +} + +@Injectable() +export class CartService { + private readonly CART_TTL = 1200; // 20 minutes in seconds + private readonly CART_KEY_PREFIX = 'cart'; + + constructor( + private readonly em: EntityManager, + private readonly cacheService: CacheService, + ) {} + + async create(userId: string, restaurantId: string, createCartDto: CreateCartDto): Promise { + const { items } = createCartDto; + + // Find restaurant + const restaurant = await this.em.findOne(Restaurant, { id: restaurantId }); + if (!restaurant) { + throw new NotFoundException('Restaurant not found'); + } + + // Check if user already has an active cart for this restaurant + const existingCart = await this.getCartByRestaurant(userId, restaurantId); + + let cart: Cart; + if (existingCart) { + // Update existing cart + cart = existingCart; + cart.items = []; + } else { + // Create new cart + const now = new Date().toISOString(); + cart = { + id: randomUUID(), + userId, + restaurantId, + restaurantName: restaurant.name, + items: [], + totalPrice: 0, + totalDiscount: 0, + finalPrice: 0, + totalItems: 0, + createdAt: now, + updatedAt: now, + }; + } + + // Add items to cart + let totalPrice = 0; + let totalDiscount = 0; + let totalItems = 0; + + for (const itemDto of items) { + const food = await this.em.findOne(Food, { id: itemDto.foodId }, { populate: ['restaurant'] }); + if (!food) { + throw new NotFoundException(`Food with ID ${itemDto.foodId} not found`); + } + + // Check if food belongs to the restaurant + if (food.restaurant.id !== restaurantId) { + throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`); + } + + // Check stock availability + if (food.stock < itemDto.quantity) { + throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`); + } + + // Calculate item price (considering discount) + const itemPrice = food.price || 0; + const itemDiscount = food.discount || 0; + const itemTotalPrice = itemPrice * itemDto.quantity; + const itemTotalDiscount = itemDiscount * itemDto.quantity; + const itemFinalPrice = itemTotalPrice - itemTotalDiscount; + + const cartItem: CartItem = { + foodId: food.id, + foodTitle: food.title, + quantity: itemDto.quantity, + price: itemPrice, + discount: itemDiscount, + totalPrice: itemFinalPrice, + }; + + cart.items.push(cartItem); + totalPrice += itemTotalPrice; + totalDiscount += itemTotalDiscount; + totalItems += itemDto.quantity; + } + + // Update cart totals + cart.totalPrice = totalPrice; + cart.totalDiscount = totalDiscount; + cart.finalPrice = totalPrice - totalDiscount; + cart.totalItems = totalItems; + cart.updatedAt = new Date().toISOString(); + + // Save to cache + await this.saveCart(cart); + // Invalidate user carts list cache + await this.invalidateUserCartsCache(userId); + + return cart; + } + + async findByUser(userId: string): Promise { + const cacheKey = `${this.CART_KEY_PREFIX}:user:${userId}`; + + // Try to get from cache first + const cachedCarts = await this.cacheService.get(cacheKey); + if (cachedCarts) { + try { + const parsed: unknown = JSON.parse(cachedCarts); + if (Array.isArray(parsed) && parsed.every((item): item is Cart => this.isCart(item))) { + return parsed; + } + } catch { + // If parsing fails, continue to fetch from cache + } + } + + // Get all cart keys for this user + // Since we can't list all keys in cache, we'll need to store a list of cart IDs + const userCartIdsKey = `${this.CART_KEY_PREFIX}:user:${userId}:ids`; + const cartIdsStr = await this.cacheService.get(userCartIdsKey); + + if (!cartIdsStr) { + return []; + } + + try { + const parsedIds: unknown = JSON.parse(cartIdsStr); + if (!Array.isArray(parsedIds) || !parsedIds.every((id): id is string => typeof id === 'string')) { + return []; + } + + const cartIds: string[] = parsedIds; + const carts: Cart[] = []; + + for (const cartId of cartIds) { + const cartKey = `${this.CART_KEY_PREFIX}:${userId}:${cartId}`; + const cartStr = await this.cacheService.get(cartKey); + if (cartStr) { + try { + const parsed: unknown = JSON.parse(cartStr); + if (this.isCart(parsed)) { + carts.push(parsed); + } + } catch { + // Skip invalid cart + } + } + } + + // Sort by updatedAt descending + carts.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + + // Cache the result + await this.cacheService.set(cacheKey, JSON.stringify(carts), this.CART_TTL); + + return carts; + } catch { + return []; + } + } + + async findOne(id: string, userId: string): Promise { + const cacheKey = `${this.CART_KEY_PREFIX}:${userId}:${id}`; + + // Try to get from cache + const cachedCart = await this.cacheService.get(cacheKey); + if (cachedCart) { + try { + const parsed: unknown = JSON.parse(cachedCart); + if (this.isCart(parsed) && parsed.id === id && parsed.userId === userId) { + return parsed; + } + } catch { + // If parsing fails, continue + } + } + + throw new NotFoundException('Cart not found'); + } + + async update(id: string, userId: string, updateCartDto: UpdateCartDto): Promise { + const cart = await this.findOne(id, userId); + + if (updateCartDto.items && updateCartDto.items.length > 0) { + // Clear existing items + cart.items = []; + + // Add new items + let totalPrice = 0; + let totalDiscount = 0; + let totalItems = 0; + + for (const itemDto of updateCartDto.items) { + if (!itemDto.foodId || !itemDto.quantity) continue; + + const food = await this.em.findOne(Food, { id: itemDto.foodId }, { populate: ['restaurant'] }); + if (!food) { + throw new NotFoundException(`Food with ID ${itemDto.foodId} not found`); + } + + // Check if food belongs to the restaurant + if (food.restaurant.id !== cart.restaurantId) { + throw new BadRequestException( + `Food ${food.title || food.id} does not belong to restaurant ${cart.restaurantId}`, + ); + } + + // Check stock + if (food.stock < itemDto.quantity) { + throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`); + } + + const itemPrice = food.price || 0; + const itemDiscount = food.discount || 0; + const itemTotalPrice = itemPrice * itemDto.quantity; + const itemTotalDiscount = itemDiscount * itemDto.quantity; + const itemFinalPrice = itemTotalPrice - itemTotalDiscount; + + const cartItem: CartItem = { + foodId: food.id, + foodTitle: food.title, + quantity: itemDto.quantity, + price: itemPrice, + discount: itemDiscount, + totalPrice: itemFinalPrice, + }; + + cart.items.push(cartItem); + totalPrice += itemTotalPrice; + totalDiscount += itemTotalDiscount; + totalItems += itemDto.quantity; + } + + cart.totalPrice = totalPrice; + cart.totalDiscount = totalDiscount; + cart.finalPrice = totalPrice - totalDiscount; + cart.totalItems = totalItems; + cart.updatedAt = new Date().toISOString(); + } + + // Save to cache + await this.saveCart(cart); + // Invalidate user carts list cache + await this.invalidateUserCartsCache(userId); + + return cart; + } + + async remove(id: string, userId: string): Promise { + await this.findOne(id, userId); + + // Remove from cache + await this.invalidateCartCache(userId, id); + await this.invalidateUserCartsCache(userId); + + // Remove from user's cart IDs list + await this.removeCartIdFromUserList(userId, id); + } + + async clearUserCart(userId: string, restaurantId?: string): Promise { + const carts = await this.findByUser(userId); + + const cartsToRemove = restaurantId ? carts.filter(cart => cart.restaurantId === restaurantId) : carts; + + for (const cart of cartsToRemove) { + await this.invalidateCartCache(userId, cart.id); + await this.removeCartIdFromUserList(userId, cart.id); + } + + // Invalidate user carts list cache + await this.invalidateUserCartsCache(userId); + } + + /** + * Type guard to check if an object is a Cart + */ + private isCart(obj: unknown): obj is Cart { + if (typeof obj !== 'object' || obj === null) { + return false; + } + const cart = obj as Record; + return ( + typeof cart.id === 'string' && + typeof cart.userId === 'string' && + typeof cart.restaurantId === 'string' && + Array.isArray(cart.items) && + typeof cart.totalPrice === 'number' && + typeof cart.totalDiscount === 'number' && + typeof cart.finalPrice === 'number' && + typeof cart.totalItems === 'number' && + typeof cart.createdAt === 'string' && + typeof cart.updatedAt === 'string' + ); + } + + /** + * Get cart by restaurant for a user + */ + private async getCartByRestaurant(userId: string, restaurantId: string): Promise { + const carts = await this.findByUser(userId); + return carts.find(cart => cart.restaurantId === restaurantId) || null; + } + + /** + * Save cart to cache + */ + private async saveCart(cart: Cart): Promise { + const cacheKey = `${this.CART_KEY_PREFIX}:${cart.userId}:${cart.id}`; + await this.cacheService.set(cacheKey, JSON.stringify(cart), this.CART_TTL); + + // Add cart ID to user's cart IDs list + await this.addCartIdToUserList(cart.userId, cart.id); + } + + /** + * Add cart ID to user's cart IDs list + */ + private async addCartIdToUserList(userId: string, cartId: string): Promise { + const userCartIdsKey = `${this.CART_KEY_PREFIX}:user:${userId}:ids`; + const cartIdsStr = await this.cacheService.get(userCartIdsKey); + + let cartIds: string[] = []; + if (cartIdsStr) { + try { + const parsed: unknown = JSON.parse(cartIdsStr); + if (Array.isArray(parsed) && parsed.every((id): id is string => typeof id === 'string')) { + cartIds = parsed; + } + } catch { + cartIds = []; + } + } + + if (!cartIds.includes(cartId)) { + cartIds.push(cartId); + await this.cacheService.set(userCartIdsKey, JSON.stringify(cartIds), this.CART_TTL); + } + } + + /** + * Remove cart ID from user's cart IDs list + */ + private async removeCartIdFromUserList(userId: string, cartId: string): Promise { + const userCartIdsKey = `${this.CART_KEY_PREFIX}:user:${userId}:ids`; + const cartIdsStr = await this.cacheService.get(userCartIdsKey); + + if (!cartIdsStr) { + return; + } + + try { + const parsed: unknown = JSON.parse(cartIdsStr); + if (Array.isArray(parsed) && parsed.every((id): id is string => typeof id === 'string')) { + const cartIds: string[] = parsed; + const filteredIds = cartIds.filter(id => id !== cartId); + await this.cacheService.set(userCartIdsKey, JSON.stringify(filteredIds), this.CART_TTL); + } + } catch { + // Ignore errors + } + } + + /** + * Invalidate a specific cart cache + */ + private async invalidateCartCache(userId: string, cartId: string): Promise { + const cacheKey = `${this.CART_KEY_PREFIX}:${userId}:${cartId}`; + await this.cacheService.del(cacheKey); + } + + /** + * Invalidate user carts list cache + */ + private async invalidateUserCartsCache(userId: string): Promise { + const cacheKey = `${this.CART_KEY_PREFIX}:user:${userId}`; + await this.cacheService.del(cacheKey); + } +}