cart
This commit is contained in:
@@ -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: [],
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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<Cart> {
|
||||
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<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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
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<Cart | null> {
|
||||
const carts = await this.findByUser(userId);
|
||||
return carts.find(cart => cart.restaurantId === restaurantId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save cart to cache
|
||||
*/
|
||||
private async saveCart(cart: Cart): Promise<void> {
|
||||
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<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