cart
This commit is contained in:
@@ -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 { Request } from 'express';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
@@ -11,6 +11,8 @@ export interface AdminAuthRequest extends Request {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
|
private readonly logger = new Logger(AuthGuard.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(JwtService)
|
@Inject(JwtService)
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
@@ -32,6 +34,10 @@ export class AuthGuard implements CanActivate {
|
|||||||
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token, {
|
const payload = await this.jwtService.verifyAsync<ITokenPayload>(token, {
|
||||||
secret,
|
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['userId'] = payload.userId;
|
||||||
request['restId'] = payload.restId;
|
request['restId'] = payload.restId;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -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 { CartService } from '../providers/cart.service';
|
||||||
import { CreateCartDto } from '../dto/create-cart.dto';
|
import { AddItemToCartDto } from '../dto/add-item.dto';
|
||||||
import { UpdateCartDto } from '../dto/update-cart.dto';
|
import { UpdateItemQuantityDto } from '../dto/update-item.dto';
|
||||||
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
|
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 { AuthGuard } from '../../auth/guards/auth.guard';
|
||||||
import { UserId } from 'src/common/decorators/user-id.decorator';
|
import { UserId } from 'src/common/decorators/user-id.decorator';
|
||||||
import { RestId } from 'src/common/decorators/rest-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 {
|
export class CartController {
|
||||||
constructor(private readonly cartService: CartService) {}
|
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()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Get all active carts for the current user' })
|
@ApiOperation({ summary: 'Get cart for current user and restaurant' })
|
||||||
@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: 200, description: 'Cart retrieved successfully' })
|
||||||
@ApiResponse({ status: 404, description: 'Cart not found' })
|
@ApiResponse({ status: 404, description: 'Cart not found' })
|
||||||
async findOne(@Param('id') id: string, @UserId() userId: string) {
|
async findOne(@UserId() userId: string, @RestId() restaurantId: string) {
|
||||||
return this.cartService.findOne(id, userId);
|
return this.cartService.findOne(userId, restaurantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Post('items')
|
||||||
@ApiOperation({ summary: 'Update a cart' })
|
@ApiOperation({ summary: 'Add item to cart (increments quantity if item exists)' })
|
||||||
@ApiBody({ type: UpdateCartDto })
|
@ApiBody({ type: AddItemToCartDto })
|
||||||
@ApiResponse({ status: 200, description: 'Cart updated successfully' })
|
@ApiResponse({ status: 201, description: 'Item added to cart successfully' })
|
||||||
@ApiResponse({ status: 404, description: 'Cart not found' })
|
@ApiResponse({ status: 400, description: 'Bad request (e.g., insufficient stock)' })
|
||||||
async update(@Param('id') id: string, @Body() updateCartDto: UpdateCartDto, @UserId() userId: string) {
|
@ApiResponse({ status: 404, description: 'Food not found' })
|
||||||
return this.cartService.update(id, userId, updateCartDto);
|
async addItem(@UserId() userId: string, @RestId() restaurantId: string, @Body() addItemDto: AddItemToCartDto) {
|
||||||
|
return this.cartService.addItem(userId, restaurantId, addItemDto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('clear')
|
@Patch('items/:itemId')
|
||||||
@ApiOperation({ summary: 'Clear all active carts for the current user' })
|
@ApiOperation({ summary: 'Update item quantity in cart' })
|
||||||
@ApiResponse({ status: 200, description: 'All carts cleared successfully' })
|
@ApiParam({ name: 'itemId', description: 'Item ID in the cart' })
|
||||||
async clearAll(@UserId() userId: string) {
|
@ApiBody({ type: UpdateItemQuantityDto })
|
||||||
return this.cartService.clearUserCart(userId);
|
@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')
|
@Delete('items/:itemId')
|
||||||
@ApiOperation({ summary: 'Delete a cart (soft delete)' })
|
@ApiOperation({ summary: 'Remove item from cart' })
|
||||||
@ApiResponse({ status: 200, description: 'Cart deleted successfully' })
|
@ApiParam({ name: 'itemId', description: 'Item ID in the cart' })
|
||||||
@ApiResponse({ status: 404, description: 'Cart not found' })
|
@ApiResponse({ status: 200, description: 'Item removed from cart successfully' })
|
||||||
async remove(@Param('id') id: string, @UserId() userId: string) {
|
@ApiResponse({ status: 404, description: 'Item not found in cart' })
|
||||||
return this.cartService.remove(id, userId);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,12 +2,14 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
|||||||
import { Food } from '../../foods/entities/food.entity';
|
import { Food } from '../../foods/entities/food.entity';
|
||||||
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
import { Restaurant } from '../../restaurants/entities/restaurant.entity';
|
||||||
import { EntityManager } from '@mikro-orm/postgresql';
|
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 { 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 {
|
export interface CartItem {
|
||||||
|
itemId: string;
|
||||||
foodId: string;
|
foodId: string;
|
||||||
foodTitle?: string;
|
foodTitle?: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
@@ -16,14 +18,22 @@ export interface CartItem {
|
|||||||
totalPrice: number;
|
totalPrice: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CartCoupon {
|
||||||
|
code: string;
|
||||||
|
discount: number;
|
||||||
|
discountType: 'amount' | 'percentage';
|
||||||
|
}
|
||||||
|
|
||||||
export interface Cart {
|
export interface Cart {
|
||||||
id: string;
|
|
||||||
userId: string;
|
userId: string;
|
||||||
restaurantId: string;
|
restaurantId: string;
|
||||||
restaurantName?: string;
|
restaurantName?: string;
|
||||||
items: CartItem[];
|
items: CartItem[];
|
||||||
|
coupon?: CartCoupon;
|
||||||
totalPrice: number;
|
totalPrice: number;
|
||||||
totalDiscount: number;
|
totalDiscount: number;
|
||||||
|
couponDiscount: number;
|
||||||
|
vatAmount: number;
|
||||||
finalPrice: number;
|
finalPrice: number;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -40,8 +50,14 @@ export class CartService {
|
|||||||
private readonly cacheService: CacheService,
|
private readonly cacheService: CacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async create(userId: string, restaurantId: string, createCartDto: CreateCartDto): Promise<Cart> {
|
/**
|
||||||
const { items } = createCartDto;
|
* Get or create cart for user and restaurant
|
||||||
|
*/
|
||||||
|
async getOrCreateCart(userId: string, restaurantId: string): Promise<Cart> {
|
||||||
|
const existingCart = await this.getCartByRestaurant(userId, restaurantId);
|
||||||
|
if (existingCart) {
|
||||||
|
return existingCart;
|
||||||
|
}
|
||||||
|
|
||||||
// Find restaurant
|
// Find restaurant
|
||||||
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
const restaurant = await this.em.findOne(Restaurant, { id: restaurantId });
|
||||||
@@ -49,261 +65,270 @@ export class CartService {
|
|||||||
throw new NotFoundException('Restaurant not found');
|
throw new NotFoundException('Restaurant not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user already has an active cart for this restaurant
|
// Create new cart
|
||||||
const existingCart = await this.getCartByRestaurant(userId, restaurantId);
|
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;
|
await this.saveCart(cart);
|
||||||
if (existingCart) {
|
return cart;
|
||||||
// Update existing cart
|
}
|
||||||
cart = existingCart;
|
|
||||||
cart.items = [];
|
/**
|
||||||
} else {
|
* Find cart by user and restaurant
|
||||||
// Create new cart
|
*/
|
||||||
const now = new Date().toISOString();
|
async findOne(userId: string, restaurantId: string): Promise<Cart> {
|
||||||
cart = {
|
const cart = await this.getCartByRestaurant(userId, restaurantId);
|
||||||
id: randomUUID(),
|
if (!cart) {
|
||||||
userId,
|
throw new NotFoundException('Cart not found');
|
||||||
restaurantId,
|
}
|
||||||
restaurantName: restaurant.name,
|
return cart;
|
||||||
items: [],
|
}
|
||||||
totalPrice: 0,
|
|
||||||
totalDiscount: 0,
|
/**
|
||||||
finalPrice: 0,
|
* Add item to cart (increment quantity if item exists, otherwise create new)
|
||||||
totalItems: 0,
|
*/
|
||||||
createdAt: now,
|
async addItem(userId: string, restaurantId: string, addItemDto: AddItemToCartDto): Promise<Cart> {
|
||||||
updatedAt: now,
|
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
|
// Check if food belongs to the restaurant
|
||||||
let totalPrice = 0;
|
if (food.restaurant.id !== restaurantId) {
|
||||||
let totalDiscount = 0;
|
throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`);
|
||||||
let totalItems = 0;
|
}
|
||||||
|
|
||||||
for (const itemDto of items) {
|
// Check stock availability
|
||||||
const food = await this.em.findOne(Food, { id: itemDto.foodId }, { populate: ['restaurant'] });
|
if (food.stock < addItemDto.quantity) {
|
||||||
if (!food) {
|
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`);
|
||||||
throw new NotFoundException(`Food with ID ${itemDto.foodId} not found`);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check if food belongs to the restaurant
|
// Check if item already exists in cart
|
||||||
if (food.restaurant.id !== restaurantId) {
|
const existingItemIndex = cart.items.findIndex(item => item.foodId === addItemDto.foodId);
|
||||||
throw new BadRequestException(`Food ${food.title || food.id} does not belong to restaurant ${restaurantId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check stock availability
|
if (existingItemIndex >= 0) {
|
||||||
if (food.stock < itemDto.quantity) {
|
// 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}`);
|
throw new BadRequestException(`Insufficient stock for food ${food.title}. Available: ${food.stock}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate item price (considering discount)
|
|
||||||
const itemPrice = food.price || 0;
|
const itemPrice = food.price || 0;
|
||||||
const itemDiscount = food.discount || 0;
|
const itemDiscount = food.discount || 0;
|
||||||
const itemTotalPrice = itemPrice * itemDto.quantity;
|
const itemTotalPrice = itemPrice * newQuantity;
|
||||||
const itemTotalDiscount = itemDiscount * itemDto.quantity;
|
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 itemFinalPrice = itemTotalPrice - itemTotalDiscount;
|
||||||
|
|
||||||
const cartItem: CartItem = {
|
const cartItem: CartItem = {
|
||||||
|
itemId: ulid(),
|
||||||
foodId: food.id,
|
foodId: food.id,
|
||||||
foodTitle: food.title,
|
foodTitle: food.title,
|
||||||
quantity: itemDto.quantity,
|
quantity: addItemDto.quantity,
|
||||||
price: itemPrice,
|
price: itemPrice,
|
||||||
discount: itemDiscount,
|
discount: itemDiscount,
|
||||||
totalPrice: itemFinalPrice,
|
totalPrice: itemFinalPrice,
|
||||||
};
|
};
|
||||||
|
|
||||||
cart.items.push(cartItem);
|
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<Cart> {
|
||||||
|
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<Cart> {
|
||||||
|
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<Cart> {
|
||||||
|
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<Cart> {
|
||||||
|
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<void> {
|
||||||
|
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.totalPrice = totalPrice;
|
||||||
cart.totalDiscount = totalDiscount;
|
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.totalItems = totalItems;
|
||||||
cart.updatedAt = new Date().toISOString();
|
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<Cart[]> {
|
|
||||||
const cacheKey = `${this.CART_KEY_PREFIX}:user:${userId}`;
|
|
||||||
|
|
||||||
// Try to get from cache first
|
|
||||||
const cachedCarts = await this.cacheService.get<string>(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<string>(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<string>(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<Cart> {
|
|
||||||
const cacheKey = `${this.CART_KEY_PREFIX}:${userId}:${id}`;
|
|
||||||
|
|
||||||
// Try to get from cache
|
|
||||||
const cachedCart = await this.cacheService.get<string>(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<Cart> {
|
|
||||||
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<void> {
|
|
||||||
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<void> {
|
|
||||||
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<string, unknown>;
|
const cart = obj as Record<string, unknown>;
|
||||||
return (
|
return (
|
||||||
typeof cart.id === 'string' &&
|
|
||||||
typeof cart.userId === 'string' &&
|
typeof cart.userId === 'string' &&
|
||||||
typeof cart.restaurantId === 'string' &&
|
typeof cart.restaurantId === 'string' &&
|
||||||
Array.isArray(cart.items) &&
|
Array.isArray(cart.items) &&
|
||||||
typeof cart.totalPrice === 'number' &&
|
typeof cart.totalPrice === 'number' &&
|
||||||
typeof cart.totalDiscount === 'number' &&
|
typeof cart.totalDiscount === 'number' &&
|
||||||
|
typeof cart.couponDiscount === 'number' &&
|
||||||
|
typeof cart.vatAmount === 'number' &&
|
||||||
typeof cart.finalPrice === 'number' &&
|
typeof cart.finalPrice === 'number' &&
|
||||||
typeof cart.totalItems === 'number' &&
|
typeof cart.totalItems === 'number' &&
|
||||||
typeof cart.createdAt === 'string' &&
|
typeof cart.createdAt === 'string' &&
|
||||||
@@ -332,82 +358,28 @@ export class CartService {
|
|||||||
* Get cart by restaurant for a user
|
* Get cart by restaurant for a user
|
||||||
*/
|
*/
|
||||||
private async getCartByRestaurant(userId: string, restaurantId: string): Promise<Cart | null> {
|
private async getCartByRestaurant(userId: string, restaurantId: string): Promise<Cart | null> {
|
||||||
const carts = await this.findByUser(userId);
|
const cacheKey = `${this.CART_KEY_PREFIX}:${userId}:${restaurantId}`;
|
||||||
return carts.find(cart => cart.restaurantId === restaurantId) || null;
|
const cachedCart = await this.cacheService.get<string>(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
|
* Save cart to cache
|
||||||
*/
|
*/
|
||||||
private async saveCart(cart: Cart): Promise<void> {
|
private async saveCart(cart: Cart): Promise<void> {
|
||||||
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);
|
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<void> {
|
|
||||||
const userCartIdsKey = `${this.CART_KEY_PREFIX}:user:${userId}:ids`;
|
|
||||||
const cartIdsStr = await this.cacheService.get<string>(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<void> {
|
|
||||||
const userCartIdsKey = `${this.CART_KEY_PREFIX}:user:${userId}:ids`;
|
|
||||||
const cartIdsStr = await this.cacheService.get<string>(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<void> {
|
|
||||||
const cacheKey = `${this.CART_KEY_PREFIX}:${userId}:${cartId}`;
|
|
||||||
await this.cacheService.del(cacheKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate user carts list cache
|
|
||||||
*/
|
|
||||||
private async invalidateUserCartsCache(userId: string): Promise<void> {
|
|
||||||
const cacheKey = `${this.CART_KEY_PREFIX}:user:${userId}`;
|
|
||||||
await this.cacheService.del(cacheKey);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user