This commit is contained in:
2025-11-23 00:02:26 +03:30
parent cd4dc69aef
commit c8b0ee3c56
6 changed files with 373 additions and 345 deletions
+276 -304
View File
@@ -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<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
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<Cart> {
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<Cart> {
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<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.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<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>;
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<Cart | null> {
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<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
*/
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);
// 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);
}
}