From f6554d2ad0e4ad88e4ad2d5fa4f1807377ba273f Mon Sep 17 00:00:00 2001 From: 2ManyProjects Date: Fri, 25 Apr 2025 16:58:05 -0500 Subject: [PATCH] cart quantity protections --- backend/src/routes/cart.js | 137 ++++++++++++++++++++++---------- backend/src/routes/products.js | 103 +++++++++++++++++------- frontend/src/pages/CartPage.jsx | 7 +- 3 files changed, 170 insertions(+), 77 deletions(-) diff --git a/backend/src/routes/cart.js b/backend/src/routes/cart.js index 4011487..49dfafb 100644 --- a/backend/src/routes/cart.js +++ b/backend/src/routes/cart.js @@ -98,9 +98,9 @@ module.exports = (pool, query, authMiddleware) => { message: 'You can only modify your own cart' }); } - // Check if product exists + // Check if product exists and verify stock const productResult = await query( - 'SELECT * FROM products WHERE id = $1', + 'SELECT id, name, stock_quantity FROM products WHERE id = $1', [productId] ); @@ -111,6 +111,8 @@ module.exports = (pool, query, authMiddleware) => { }); } + const product = productResult.rows[0]; + // Get or create cart let cartResult = await query( 'SELECT * FROM carts WHERE user_id = $1', @@ -132,10 +134,34 @@ module.exports = (pool, query, authMiddleware) => { [cartId, productId] ); + let newQuantity = quantity; + + if (existingItemResult.rows.length > 0) { + // If item already exists, calculate new total quantity + newQuantity = existingItemResult.rows[0].quantity + quantity; + } + + if (quantity < 1) { + return res.status(400).json({ + error: true, + message: `Insufficient quantity requested. Only ${product.stock_quantity} units of "${product.name}" are available.`, + availableQuantity: product.stock_quantity, + currentCartQuantity: existingItemResult.rows.length > 0 ? existingItemResult.rows[0].quantity : 0 + }); + } + + // Verify the total requested quantity is available in stock + if (newQuantity > product.stock_quantity) { + return res.status(400).json({ + error: true, + message: `Insufficient stock. Only ${product.stock_quantity} units of "${product.name}" are available.`, + availableQuantity: product.stock_quantity, + currentCartQuantity: existingItemResult.rows.length > 0 ? existingItemResult.rows[0].quantity : 0 + }); + } + if (existingItemResult.rows.length > 0) { // Update quantity - const newQuantity = existingItemResult.rows[0].quantity + quantity; - await query( 'UPDATE cart_items SET quantity = $1 WHERE id = $2', [newQuantity, existingItemResult.rows[0].id] @@ -151,25 +177,25 @@ module.exports = (pool, query, authMiddleware) => { // Get updated cart const updatedCartItems = await query( `SELECT ci.id, ci.quantity, ci.added_at, - p.id AS product_id, p.name, p.description, p.price, - p.category_id, pc.name AS category_name, - ( - SELECT json_agg( - json_build_object( - 'id', pi.id, - 'path', pi.image_path, - 'isPrimary', pi.is_primary, - 'displayOrder', pi.display_order - ) ORDER BY pi.display_order - ) - FROM product_images pi - WHERE pi.product_id = p.id - ) AS images - FROM cart_items ci - JOIN products p ON ci.product_id = p.id - JOIN product_categories pc ON p.category_id = pc.id - WHERE ci.cart_id = $1 - GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.category_id, pc.name`, + p.id AS product_id, p.name, p.description, p.price, p.stock_quantity, + p.category_id, pc.name AS category_name, + ( + SELECT json_agg( + json_build_object( + 'id', pi.id, + 'path', pi.image_path, + 'isPrimary', pi.is_primary, + 'displayOrder', pi.display_order + ) ORDER BY pi.display_order + ) + FROM product_images pi + WHERE pi.product_id = p.id + ) AS images + FROM cart_items ci + JOIN products p ON ci.product_id = p.id + JOIN product_categories pc ON p.category_id = pc.id + WHERE ci.cart_id = $1 + GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.stock_quantity, p.category_id, pc.name`, [cartId] ); @@ -214,6 +240,7 @@ module.exports = (pool, query, authMiddleware) => { message: 'You can only modify your own cart' }); } + // Get cart const cartResult = await query( 'SELECT * FROM carts WHERE user_id = $1', @@ -236,7 +263,31 @@ module.exports = (pool, query, authMiddleware) => { [cartId, productId] ); } else { - // Update quantity + // Check product availability before updating + const productResult = await query( + 'SELECT id, name, stock_quantity FROM products WHERE id = $1', + [productId] + ); + + if (productResult.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Product not found' + }); + } + + const product = productResult.rows[0]; + + // Verify the requested quantity is available in stock + if (quantity > product.stock_quantity) { + return res.status(400).json({ + error: true, + message: `Insufficient stock. Only ${product.stock_quantity} units of "${product.name}" are available.`, + availableQuantity: product.stock_quantity + }); + } + + // Update quantity if stock is sufficient await query( 'UPDATE cart_items SET quantity = $1 WHERE cart_id = $2 AND product_id = $3', [quantity, cartId, productId] @@ -246,25 +297,25 @@ module.exports = (pool, query, authMiddleware) => { // Get updated cart const updatedCartItems = await query( `SELECT ci.id, ci.quantity, ci.added_at, - p.id AS product_id, p.name, p.description, p.price, - p.category_id, pc.name AS category_name, - ( - SELECT json_agg( - json_build_object( - 'id', pi.id, - 'path', pi.image_path, - 'isPrimary', pi.is_primary, - 'displayOrder', pi.display_order - ) ORDER BY pi.display_order - ) - FROM product_images pi - WHERE pi.product_id = p.id - ) AS images - FROM cart_items ci - JOIN products p ON ci.product_id = p.id - JOIN product_categories pc ON p.category_id = pc.id - WHERE ci.cart_id = $1 - GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.category_id, pc.name`, + p.id AS product_id, p.name, p.description, p.price, p.stock_quantity, + p.category_id, pc.name AS category_name, + ( + SELECT json_agg( + json_build_object( + 'id', pi.id, + 'path', pi.image_path, + 'isPrimary', pi.is_primary, + 'displayOrder', pi.display_order + ) ORDER BY pi.display_order + ) + FROM product_images pi + WHERE pi.product_id = p.id + ) AS images + FROM cart_items ci + JOIN products p ON ci.product_id = p.id + JOIN product_categories pc ON p.category_id = pc.id + WHERE ci.cart_id = $1 + GROUP BY ci.id, ci.quantity, ci.added_at, p.id, p.name, p.description, p.price, p.stock_quantity, p.category_id, pc.name`, [cartId] ); diff --git a/backend/src/routes/products.js b/backend/src/routes/products.js index d5b118b..97cf761 100644 --- a/backend/src/routes/products.js +++ b/backend/src/routes/products.js @@ -80,41 +80,84 @@ module.exports = (pool, qry) => { } }); - // Get single product by ID + // Get single product by ID or IDS if multiple are passed in as a comma seperated string router.get('/:id', async (req, res, next) => { try { const { id } = req.params; - const query = ` - SELECT p.*, pc.name as category_name, - ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags, - json_agg( - json_build_object( - 'id', pi.id, - 'path', pi.image_path, - 'isPrimary', pi.is_primary, - 'displayOrder', pi.display_order - ) ORDER BY pi.display_order - ) FILTER (WHERE pi.id IS NOT NULL) AS images - FROM products p - JOIN product_categories pc ON p.category_id = pc.id - LEFT JOIN product_tags pt ON p.id = pt.product_id - LEFT JOIN tags t ON pt.tag_id = t.id - LEFT JOIN product_images pi ON p.id = pi.product_id - WHERE p.id = $1 - GROUP BY p.id, pc.name - `; - - const result = await qry(query, [id]); - - if (result.rows.length === 0) { - return res.status(404).json({ - error: true, - message: 'Product not found' - }); + // Check if comma is present in the ID parameter + if (id.includes(',')) { + // Handle multiple product IDs + const productIds = id.split(',').map(item => item.trim()); + + const placeholders = productIds.map((_, index) => `$${index + 1}`).join(','); + + const query = ` + SELECT p.*, pc.name as category_name, + ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags, + json_agg( + json_build_object( + 'id', pi.id, + 'path', pi.image_path, + 'isPrimary', pi.is_primary, + 'displayOrder', pi.display_order + ) ORDER BY pi.display_order + ) FILTER (WHERE pi.id IS NOT NULL) AS images + FROM products p + JOIN product_categories pc ON p.category_id = pc.id + LEFT JOIN product_tags pt ON p.id = pt.product_id + LEFT JOIN tags t ON pt.tag_id = t.id + LEFT JOIN product_images pi ON p.id = pi.product_id + WHERE p.id IN (${placeholders}) + GROUP BY p.id, pc.name + `; + + const result = await qry(query, productIds); + + if (result.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'No products found' + }); + } + + // Return array of products + res.json(result.rows); + + } else { + // Handle single product ID (original code) + const query = ` + SELECT p.*, pc.name as category_name, + ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags, + json_agg( + json_build_object( + 'id', pi.id, + 'path', pi.image_path, + 'isPrimary', pi.is_primary, + 'displayOrder', pi.display_order + ) ORDER BY pi.display_order + ) FILTER (WHERE pi.id IS NOT NULL) AS images + FROM products p + JOIN product_categories pc ON p.category_id = pc.id + LEFT JOIN product_tags pt ON p.id = pt.product_id + LEFT JOIN tags t ON pt.tag_id = t.id + LEFT JOIN product_images pi ON p.id = pi.product_id + WHERE p.id = $1 + GROUP BY p.id, pc.name + `; + + const result = await qry(query, [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ + error: true, + message: 'Product not found' + }); + } + + // Return single product + res.json([result.rows[0]]); } - - res.json(result.rows[0]); } catch (error) { next(error); } diff --git a/frontend/src/pages/CartPage.jsx b/frontend/src/pages/CartPage.jsx index 2eb9bc0..4d409e6 100644 --- a/frontend/src/pages/CartPage.jsx +++ b/frontend/src/pages/CartPage.jsx @@ -22,7 +22,7 @@ import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { useAuth } from '@hooks/reduxHooks'; -import { useGetCart, useUpdateCartItem, useClearCart } from '@hooks/apiHooks'; +import { useGetCart, useUpdateCartItem, useClearCart, useProduct } from '@hooks/apiHooks'; import imageUtils from '@utils/imageUtils'; const CartPage = () => { @@ -31,7 +31,7 @@ const CartPage = () => { // Get cart data const { data: cart, isLoading, error } = useGetCart(user); - + const { data: products } = useProduct(cart?.items.map(item => item.product_id).join(",")); // Cart mutations const updateCartItem = useUpdateCartItem(); const clearCart = useClearCart(); @@ -218,11 +218,10 @@ const CartPage = () => { size="small" sx={{ width: 40, mx: 1 }} /> - handleUpdateQuantity(item.product_id, item.quantity + 1)} - disabled={updateCartItem.isLoading} + disabled={(products?.length > 0 && item.quantity >= products.find(prod => item.product_id === prod.id)?.stock_quantity)|| updateCartItem.isLoading} >