From c8b0ee3c5658d9e2bd6c79a34093a2f20aa17447 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sun, 23 Nov 2025 00:02:26 +0330 Subject: [PATCH] cart --- src/modules/auth/guards/auth.guard.ts | 8 +- .../cart/controllers/cart.controller.ts | 93 +-- src/modules/cart/dto/add-item.dto.ts | 16 + src/modules/cart/dto/apply-coupon.dto.ts | 10 + src/modules/cart/dto/update-item.dto.ts | 11 + src/modules/cart/providers/cart.service.ts | 580 +++++++++--------- 6 files changed, 373 insertions(+), 345 deletions(-) create mode 100644 src/modules/cart/dto/add-item.dto.ts create mode 100644 src/modules/cart/dto/apply-coupon.dto.ts create mode 100644 src/modules/cart/dto/update-item.dto.ts diff --git a/src/modules/auth/guards/auth.guard.ts b/src/modules/auth/guards/auth.guard.ts index 522ecae..0878d4d 100644 --- a/src/modules/auth/guards/auth.guard.ts +++ b/src/modules/auth/guards/auth.guard.ts @@ -1,4 +1,4 @@ -import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, Inject } from '@nestjs/common'; +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException, Inject, Logger } from '@nestjs/common'; import { Request } from 'express'; import { JwtService } from '@nestjs/jwt'; import { ConfigService } from '@nestjs/config'; @@ -11,6 +11,8 @@ export interface AdminAuthRequest extends Request { @Injectable() export class AuthGuard implements CanActivate { + private readonly logger = new Logger(AuthGuard.name); + constructor( @Inject(JwtService) private readonly jwtService: JwtService, @@ -32,6 +34,10 @@ export class AuthGuard implements CanActivate { const payload = await this.jwtService.verifyAsync(token, { secret, }); + if (!payload.userId || !payload.restId) { + this.logger.error('Invalid token payload structure', payload); + throw new UnauthorizedException('Invalid token payload'); + } request['userId'] = payload.userId; request['restId'] = payload.restId; } catch { diff --git a/src/modules/cart/controllers/cart.controller.ts b/src/modules/cart/controllers/cart.controller.ts index b887a90..f58dcd5 100644 --- a/src/modules/cart/controllers/cart.controller.ts +++ b/src/modules/cart/controllers/cart.controller.ts @@ -1,8 +1,9 @@ -import { Controller, Get, Post, Body, Patch, Param, Delete, UseGuards } from '@nestjs/common'; +import { Controller, Get, Post, Body, Patch, Delete, UseGuards, Param } 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 { AddItemToCartDto } from '../dto/add-item.dto'; +import { UpdateItemQuantityDto } from '../dto/update-item.dto'; +import { ApplyCouponDto } from '../dto/apply-coupon.dto'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody, ApiParam } 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'; @@ -14,50 +15,62 @@ import { RestId } from 'src/common/decorators/rest-id.decorator'; 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' }) + @ApiOperation({ summary: 'Get cart for current user and restaurant' }) @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); + async findOne(@UserId() userId: string, @RestId() restaurantId: string) { + return this.cartService.findOne(userId, restaurantId); } - @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); + @Post('items') + @ApiOperation({ summary: 'Add item to cart (increments quantity if item exists)' }) + @ApiBody({ type: AddItemToCartDto }) + @ApiResponse({ status: 201, description: 'Item added to cart successfully' }) + @ApiResponse({ status: 400, description: 'Bad request (e.g., insufficient stock)' }) + @ApiResponse({ status: 404, description: 'Food not found' }) + async addItem(@UserId() userId: string, @RestId() restaurantId: string, @Body() addItemDto: AddItemToCartDto) { + return this.cartService.addItem(userId, restaurantId, addItemDto); } - @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); + @Patch('items/:itemId') + @ApiOperation({ summary: 'Update item quantity in cart' }) + @ApiParam({ name: 'itemId', description: 'Item ID in the cart' }) + @ApiBody({ type: UpdateItemQuantityDto }) + @ApiResponse({ status: 200, description: 'Item quantity updated successfully' }) + @ApiResponse({ status: 400, description: 'Bad request (e.g., insufficient stock)' }) + @ApiResponse({ status: 404, description: 'Item not found in cart' }) + async updateItemQuantity( + @UserId() userId: string, + @RestId() restaurantId: string, + @Param('itemId') itemId: string, + @Body() updateItemDto: UpdateItemQuantityDto, + ) { + return this.cartService.updateItemQuantity(userId, restaurantId, itemId, updateItemDto); } - @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); + @Delete('items/:itemId') + @ApiOperation({ summary: 'Remove item from cart' }) + @ApiParam({ name: 'itemId', description: 'Item ID in the cart' }) + @ApiResponse({ status: 200, description: 'Item removed from cart successfully' }) + @ApiResponse({ status: 404, description: 'Item not found in cart' }) + async removeItem(@UserId() userId: string, @RestId() restaurantId: string, @Param('itemId') itemId: string) { + return this.cartService.removeItem(userId, restaurantId, itemId); + } + + @Post('apply-coupon') + @ApiOperation({ summary: 'Apply coupon to cart' }) + @ApiBody({ type: ApplyCouponDto }) + @ApiResponse({ status: 200, description: 'Coupon applied successfully' }) + @ApiResponse({ status: 400, description: 'Invalid coupon code' }) + async applyCoupon(@UserId() userId: string, @RestId() restaurantId: string, @Body() applyCouponDto: ApplyCouponDto) { + return this.cartService.applyCoupon(userId, restaurantId, applyCouponDto); + } + + @Delete('coupon') + @ApiOperation({ summary: 'Remove coupon from cart' }) + @ApiResponse({ status: 200, description: 'Coupon removed successfully' }) + async removeCoupon(@UserId() userId: string, @RestId() restaurantId: string) { + return this.cartService.removeCoupon(userId, restaurantId); } } diff --git a/src/modules/cart/dto/add-item.dto.ts b/src/modules/cart/dto/add-item.dto.ts new file mode 100644 index 0000000..ec8f99d --- /dev/null +++ b/src/modules/cart/dto/add-item.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, IsNumber, Min } from 'class-validator'; + +export class AddItemToCartDto { + @ApiProperty({ description: 'Food ID' }) + @IsNotEmpty() + @IsString() + foodId!: string; + + @ApiProperty({ description: 'Quantity of the food item', example: 1, minimum: 1 }) + @IsNotEmpty() + @IsNumber() + @Min(1) + quantity!: number; +} + diff --git a/src/modules/cart/dto/apply-coupon.dto.ts b/src/modules/cart/dto/apply-coupon.dto.ts new file mode 100644 index 0000000..10f7f96 --- /dev/null +++ b/src/modules/cart/dto/apply-coupon.dto.ts @@ -0,0 +1,10 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsString } from 'class-validator'; + +export class ApplyCouponDto { + @ApiProperty({ description: 'Coupon code', example: 'DISCOUNT10' }) + @IsNotEmpty() + @IsString() + code!: string; +} + diff --git a/src/modules/cart/dto/update-item.dto.ts b/src/modules/cart/dto/update-item.dto.ts new file mode 100644 index 0000000..570282e --- /dev/null +++ b/src/modules/cart/dto/update-item.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty, IsNumber, Min } from 'class-validator'; + +export class UpdateItemQuantityDto { + @ApiProperty({ description: 'Quantity of the food item', example: 2, minimum: 1 }) + @IsNotEmpty() + @IsNumber() + @Min(1) + quantity!: number; +} + diff --git a/src/modules/cart/providers/cart.service.ts b/src/modules/cart/providers/cart.service.ts index 1043d28..9ee48e7 100644 --- a/src/modules/cart/providers/cart.service.ts +++ b/src/modules/cart/providers/cart.service.ts @@ -2,12 +2,14 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm 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'; +import { ulid } from 'ulid'; +import { AddItemToCartDto } from '../dto/add-item.dto'; +import { UpdateItemQuantityDto } from '../dto/update-item.dto'; +import { ApplyCouponDto } from '../dto/apply-coupon.dto'; export interface CartItem { + itemId: string; foodId: string; foodTitle?: string; quantity: number; @@ -16,14 +18,22 @@ export interface CartItem { totalPrice: number; } +export interface CartCoupon { + code: string; + discount: number; + discountType: 'amount' | 'percentage'; +} + export interface Cart { - id: string; userId: string; restaurantId: string; restaurantName?: string; items: CartItem[]; + coupon?: CartCoupon; totalPrice: number; totalDiscount: number; + couponDiscount: number; + vatAmount: number; finalPrice: number; totalItems: number; createdAt: string; @@ -40,8 +50,14 @@ export class CartService { private readonly cacheService: CacheService, ) {} - async create(userId: string, restaurantId: string, createCartDto: CreateCartDto): Promise { - const { items } = createCartDto; + /** + * Get or create cart for user and restaurant + */ + async getOrCreateCart(userId: string, restaurantId: string): Promise { + const existingCart = await this.getCartByRestaurant(userId, restaurantId); + if (existingCart) { + return existingCart; + } // Find restaurant const restaurant = await this.em.findOne(Restaurant, { id: restaurantId }); @@ -49,261 +65,270 @@ export class CartService { throw new NotFoundException('Restaurant not found'); } - // Check if user already has an active cart for this restaurant - const existingCart = await this.getCartByRestaurant(userId, restaurantId); + // Create new cart + const now = new Date().toISOString(); + const cart: Cart = { + userId, + restaurantId, + restaurantName: restaurant.name, + items: [], + totalPrice: 0, + totalDiscount: 0, + couponDiscount: 0, + vatAmount: 0, + finalPrice: 0, + totalItems: 0, + createdAt: now, + updatedAt: now, + }; - 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, - }; + await this.saveCart(cart); + return cart; + } + + /** + * Find cart by user and restaurant + */ + async findOne(userId: string, restaurantId: string): Promise { + const cart = await this.getCartByRestaurant(userId, restaurantId); + if (!cart) { + throw new NotFoundException('Cart not found'); + } + return cart; + } + + /** + * Add item to cart (increment quantity if item exists, otherwise create new) + */ + async addItem(userId: string, restaurantId: string, addItemDto: AddItemToCartDto): Promise { + const cart = await this.getOrCreateCart(userId, restaurantId); + + // Find food + const food = await this.em.findOne(Food, { id: addItemDto.foodId }, { populate: ['restaurant'] }); + if (!food) { + throw new NotFoundException(`Food with ID ${addItemDto.foodId} not found`); } - // Add items to cart - let totalPrice = 0; - let totalDiscount = 0; - let totalItems = 0; + // 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}`); + } - 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 stock availability + if (food.stock < addItemDto.quantity) { + throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`); + } - // 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 if item already exists in cart + const existingItemIndex = cart.items.findIndex(item => item.foodId === addItemDto.foodId); - // Check stock availability - if (food.stock < itemDto.quantity) { + if (existingItemIndex >= 0) { + // Update existing item quantity + const existingItem = cart.items[existingItemIndex]; + const newQuantity = existingItem.quantity + addItemDto.quantity; + + // Check stock for new total quantity + if (food.stock < newQuantity) { 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 itemTotalPrice = itemPrice * newQuantity; + const itemTotalDiscount = itemDiscount * newQuantity; + const itemFinalPrice = itemTotalPrice - itemTotalDiscount; + + cart.items[existingItemIndex] = { + ...existingItem, + quantity: newQuantity, + totalPrice: itemFinalPrice, + }; + } else { + // Create new item + const itemPrice = food.price || 0; + const itemDiscount = food.discount || 0; + const itemTotalPrice = itemPrice * addItemDto.quantity; + const itemTotalDiscount = itemDiscount * addItemDto.quantity; const itemFinalPrice = itemTotalPrice - itemTotalDiscount; const cartItem: CartItem = { + itemId: ulid(), foodId: food.id, foodTitle: food.title, - quantity: itemDto.quantity, + quantity: addItemDto.quantity, price: itemPrice, discount: itemDiscount, totalPrice: itemFinalPrice, }; cart.items.push(cartItem); - totalPrice += itemTotalPrice; - totalDiscount += itemTotalDiscount; - totalItems += itemDto.quantity; } - // Update cart totals + // Recalculate cart totals + await this.recalculateCartTotals(cart); + await this.saveCart(cart); + + return cart; + } + + /** + * Update item quantity in cart + */ + async updateItemQuantity( + userId: string, + restaurantId: string, + itemId: string, + updateItemDto: UpdateItemQuantityDto, + ): Promise { + const cart = await this.findOne(userId, restaurantId); + + const itemIndex = cart.items.findIndex(item => item.itemId === itemId); + if (itemIndex < 0) { + throw new NotFoundException(`Item with ID ${itemId} not found in cart`); + } + + const item = cart.items[itemIndex]; + + // Find food to check stock + const food = await this.em.findOne(Food, { id: item.foodId }, { populate: ['restaurant'] }); + if (!food) { + throw new NotFoundException(`Food with ID ${item.foodId} not found`); + } + + // Check stock availability + if (food.stock < updateItemDto.quantity) { + throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`); + } + + // Update quantity + const itemPrice = food.price || 0; + const itemDiscount = food.discount || 0; + const itemTotalPrice = itemPrice * updateItemDto.quantity; + const itemTotalDiscount = itemDiscount * updateItemDto.quantity; + const itemFinalPrice = itemTotalPrice - itemTotalDiscount; + + cart.items[itemIndex] = { + ...item, + quantity: updateItemDto.quantity, + totalPrice: itemFinalPrice, + }; + + // Recalculate cart totals + await this.recalculateCartTotals(cart); + await this.saveCart(cart); + + return cart; + } + + /** + * Remove item from cart + */ + async removeItem(userId: string, restaurantId: string, itemId: string): Promise { + const cart = await this.findOne(userId, restaurantId); + + const itemIndex = cart.items.findIndex(item => item.itemId === itemId); + if (itemIndex < 0) { + throw new NotFoundException(`Item with ID ${itemId} not found in cart`); + } + + // Remove item + cart.items.splice(itemIndex, 1); + + // Recalculate cart totals + await this.recalculateCartTotals(cart); + await this.saveCart(cart); + + return cart; + } + + /** + * Apply coupon to cart + */ + async applyCoupon(userId: string, restaurantId: string, applyCouponDto: ApplyCouponDto): Promise { + const cart = await this.findOne(userId, restaurantId); + + // TODO: Implement actual coupon validation logic + // For now, this is a placeholder implementation + // You should validate the coupon code against your coupon/discount system + + // Example coupon structure (replace with your actual coupon validation) + const coupon: CartCoupon = { + code: applyCouponDto.code, + discount: 10, // Example: 10% discount + discountType: 'percentage', // or 'amount' + }; + + cart.coupon = coupon; + + // Recalculate cart totals + await this.recalculateCartTotals(cart); + await this.saveCart(cart); + + return cart; + } + + /** + * Remove coupon from cart + */ + async removeCoupon(userId: string, restaurantId: string): Promise { + const cart = await this.findOne(userId, restaurantId); + + cart.coupon = undefined; + + // Recalculate cart totals + await this.recalculateCartTotals(cart); + await this.saveCart(cart); + + return cart; + } + + /** + * Recalculate cart totals (including coupon discount and VAT) + */ + private async recalculateCartTotals(cart: Cart): Promise { + let totalPrice = 0; + let totalDiscount = 0; + let totalItems = 0; + + for (const item of cart.items) { + const itemPrice = item.price * item.quantity; + const itemDiscount = item.discount * item.quantity; + totalPrice += itemPrice; + totalDiscount += itemDiscount; + totalItems += item.quantity; + } + cart.totalPrice = totalPrice; cart.totalDiscount = totalDiscount; - cart.finalPrice = totalPrice - totalDiscount; + + // Calculate coupon discount + let couponDiscount = 0; + if (cart.coupon) { + if (cart.coupon.discountType === 'percentage') { + const priceAfterItemDiscount = totalPrice - totalDiscount; + couponDiscount = (priceAfterItemDiscount * cart.coupon.discount) / 100; + } else { + // amount discount + const priceAfterItemDiscount = totalPrice - totalDiscount; + couponDiscount = Math.min(cart.coupon.discount, priceAfterItemDiscount); + } + } + + cart.couponDiscount = couponDiscount; + const priceAfterDiscounts = totalPrice - totalDiscount - couponDiscount; + + // Calculate VAT if restaurant has VAT rate > 0 + let vatAmount = 0; + const restaurant = await this.em.findOne(Restaurant, { id: cart.restaurantId }); + if (restaurant && restaurant.vat && restaurant.vat > 0) { + // VAT is calculated on the price after discounts and coupons + vatAmount = (priceAfterDiscounts * Number(restaurant.vat)) / 100; + } + + cart.vatAmount = vatAmount; + cart.finalPrice = priceAfterDiscounts + vatAmount; 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); } /** @@ -315,12 +340,13 @@ export class CartService { } 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.couponDiscount === 'number' && + typeof cart.vatAmount === 'number' && typeof cart.finalPrice === 'number' && typeof cart.totalItems === 'number' && typeof cart.createdAt === 'string' && @@ -332,82 +358,28 @@ export class CartService { * 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; + const cacheKey = `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`; + const cachedCart = await this.cacheService.get(cacheKey); + + if (cachedCart) { + try { + const parsed: unknown = JSON.parse(cachedCart); + if (this.isCart(parsed) && parsed.userId === userId && parsed.restaurantId === restaurantId) { + return parsed; + } + } catch { + // If parsing fails, return null + } + } + + return null; } /** * Save cart to cache */ private async saveCart(cart: Cart): Promise { - const cacheKey = `${this.CART_KEY_PREFIX}:${cart.userId}:${cart.id}`; + const cacheKey = `${this.CART_KEY_PREFIX}:${cart.userId}:${cart.restaurantId}`; 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); } }