cart quantity protections

This commit is contained in:
2ManyProjects 2025-04-25 16:58:05 -05:00
parent 57ce946666
commit f6554d2ad0
3 changed files with 170 additions and 77 deletions

View file

@ -98,9 +98,9 @@ module.exports = (pool, query, authMiddleware) => {
message: 'You can only modify your own cart' message: 'You can only modify your own cart'
}); });
} }
// Check if product exists // Check if product exists and verify stock
const productResult = await query( const productResult = await query(
'SELECT * FROM products WHERE id = $1', 'SELECT id, name, stock_quantity FROM products WHERE id = $1',
[productId] [productId]
); );
@ -111,6 +111,8 @@ module.exports = (pool, query, authMiddleware) => {
}); });
} }
const product = productResult.rows[0];
// Get or create cart // Get or create cart
let cartResult = await query( let cartResult = await query(
'SELECT * FROM carts WHERE user_id = $1', 'SELECT * FROM carts WHERE user_id = $1',
@ -132,10 +134,34 @@ module.exports = (pool, query, authMiddleware) => {
[cartId, productId] [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) { if (existingItemResult.rows.length > 0) {
// Update quantity // Update quantity
const newQuantity = existingItemResult.rows[0].quantity + quantity;
await query( await query(
'UPDATE cart_items SET quantity = $1 WHERE id = $2', 'UPDATE cart_items SET quantity = $1 WHERE id = $2',
[newQuantity, existingItemResult.rows[0].id] [newQuantity, existingItemResult.rows[0].id]
@ -151,25 +177,25 @@ module.exports = (pool, query, authMiddleware) => {
// Get updated cart // Get updated cart
const updatedCartItems = await query( const updatedCartItems = await query(
`SELECT ci.id, ci.quantity, ci.added_at, `SELECT ci.id, ci.quantity, ci.added_at,
p.id AS product_id, p.name, p.description, p.price, p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
p.category_id, pc.name AS category_name, p.category_id, pc.name AS category_name,
( (
SELECT json_agg( SELECT json_agg(
json_build_object( json_build_object(
'id', pi.id, 'id', pi.id,
'path', pi.image_path, 'path', pi.image_path,
'isPrimary', pi.is_primary, 'isPrimary', pi.is_primary,
'displayOrder', pi.display_order 'displayOrder', pi.display_order
) ORDER BY pi.display_order ) ORDER BY pi.display_order
) )
FROM product_images pi FROM product_images pi
WHERE pi.product_id = p.id WHERE pi.product_id = p.id
) AS images ) AS images
FROM cart_items ci FROM cart_items ci
JOIN products p ON ci.product_id = p.id JOIN products p ON ci.product_id = p.id
JOIN product_categories pc ON p.category_id = pc.id JOIN product_categories pc ON p.category_id = pc.id
WHERE ci.cart_id = $1 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`, 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] [cartId]
); );
@ -214,6 +240,7 @@ module.exports = (pool, query, authMiddleware) => {
message: 'You can only modify your own cart' message: 'You can only modify your own cart'
}); });
} }
// Get cart // Get cart
const cartResult = await query( const cartResult = await query(
'SELECT * FROM carts WHERE user_id = $1', 'SELECT * FROM carts WHERE user_id = $1',
@ -236,7 +263,31 @@ module.exports = (pool, query, authMiddleware) => {
[cartId, productId] [cartId, productId]
); );
} else { } 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( await query(
'UPDATE cart_items SET quantity = $1 WHERE cart_id = $2 AND product_id = $3', 'UPDATE cart_items SET quantity = $1 WHERE cart_id = $2 AND product_id = $3',
[quantity, cartId, productId] [quantity, cartId, productId]
@ -246,25 +297,25 @@ module.exports = (pool, query, authMiddleware) => {
// Get updated cart // Get updated cart
const updatedCartItems = await query( const updatedCartItems = await query(
`SELECT ci.id, ci.quantity, ci.added_at, `SELECT ci.id, ci.quantity, ci.added_at,
p.id AS product_id, p.name, p.description, p.price, p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
p.category_id, pc.name AS category_name, p.category_id, pc.name AS category_name,
( (
SELECT json_agg( SELECT json_agg(
json_build_object( json_build_object(
'id', pi.id, 'id', pi.id,
'path', pi.image_path, 'path', pi.image_path,
'isPrimary', pi.is_primary, 'isPrimary', pi.is_primary,
'displayOrder', pi.display_order 'displayOrder', pi.display_order
) ORDER BY pi.display_order ) ORDER BY pi.display_order
) )
FROM product_images pi FROM product_images pi
WHERE pi.product_id = p.id WHERE pi.product_id = p.id
) AS images ) AS images
FROM cart_items ci FROM cart_items ci
JOIN products p ON ci.product_id = p.id JOIN products p ON ci.product_id = p.id
JOIN product_categories pc ON p.category_id = pc.id JOIN product_categories pc ON p.category_id = pc.id
WHERE ci.cart_id = $1 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`, 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] [cartId]
); );

View file

@ -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) => { router.get('/:id', async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const query = ` // Check if comma is present in the ID parameter
SELECT p.*, pc.name as category_name, if (id.includes(',')) {
ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags, // Handle multiple product IDs
json_agg( const productIds = id.split(',').map(item => item.trim());
json_build_object(
'id', pi.id, const placeholders = productIds.map((_, index) => `$${index + 1}`).join(',');
'path', pi.image_path,
'isPrimary', pi.is_primary, const query = `
'displayOrder', pi.display_order SELECT p.*, pc.name as category_name,
) ORDER BY pi.display_order ARRAY_AGG(DISTINCT t.name) FILTER (WHERE t.name IS NOT NULL) AS tags,
) FILTER (WHERE pi.id IS NOT NULL) AS images json_agg(
FROM products p json_build_object(
JOIN product_categories pc ON p.category_id = pc.id 'id', pi.id,
LEFT JOIN product_tags pt ON p.id = pt.product_id 'path', pi.image_path,
LEFT JOIN tags t ON pt.tag_id = t.id 'isPrimary', pi.is_primary,
LEFT JOIN product_images pi ON p.id = pi.product_id 'displayOrder', pi.display_order
WHERE p.id = $1 ) ORDER BY pi.display_order
GROUP BY p.id, pc.name ) FILTER (WHERE pi.id IS NOT NULL) AS images
`; FROM products p
JOIN product_categories pc ON p.category_id = pc.id
const result = await qry(query, [id]); LEFT JOIN product_tags pt ON p.id = pt.product_id
LEFT JOIN tags t ON pt.tag_id = t.id
if (result.rows.length === 0) { LEFT JOIN product_images pi ON p.id = pi.product_id
return res.status(404).json({ WHERE p.id IN (${placeholders})
error: true, GROUP BY p.id, pc.name
message: 'Product not found' `;
});
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) { } catch (error) {
next(error); next(error);
} }

View file

@ -22,7 +22,7 @@ import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useAuth } from '@hooks/reduxHooks'; 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'; import imageUtils from '@utils/imageUtils';
const CartPage = () => { const CartPage = () => {
@ -31,7 +31,7 @@ const CartPage = () => {
// Get cart data // Get cart data
const { data: cart, isLoading, error } = useGetCart(user); const { data: cart, isLoading, error } = useGetCart(user);
const { data: products } = useProduct(cart?.items.map(item => item.product_id).join(","));
// Cart mutations // Cart mutations
const updateCartItem = useUpdateCartItem(); const updateCartItem = useUpdateCartItem();
const clearCart = useClearCart(); const clearCart = useClearCart();
@ -218,11 +218,10 @@ const CartPage = () => {
size="small" size="small"
sx={{ width: 40, mx: 1 }} sx={{ width: 40, mx: 1 }}
/> />
<IconButton <IconButton
size="small" size="small"
onClick={() => handleUpdateQuantity(item.product_id, item.quantity + 1)} onClick={() => 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}
> >
<AddIcon fontSize="small" /> <AddIcon fontSize="small" />
</IconButton> </IconButton>