1238 lines
No EOL
40 KiB
JavaScript
1238 lines
No EOL
40 KiB
JavaScript
const express = require('express');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const router = express.Router();
|
|
const shippingService = require('../services/shippingService.js');
|
|
const config = require('../config');
|
|
|
|
module.exports = (pool, query, authMiddleware) => {
|
|
|
|
router.use(authMiddleware);
|
|
|
|
// Get user's cart
|
|
router.get('/:userId', async (req, res, next) => {
|
|
try {
|
|
const { userId } = req.params;
|
|
if (req.user.id !== userId) {
|
|
return res.status(403).json({
|
|
error: true,
|
|
message: 'You can only access your own cart'
|
|
});
|
|
}
|
|
// Get cart
|
|
let cartResult = await query(
|
|
'SELECT * FROM carts WHERE user_id = $1',
|
|
[userId]
|
|
);
|
|
|
|
// If no cart exists, create one
|
|
if (cartResult.rows.length === 0) {
|
|
cartResult = await query(
|
|
'INSERT INTO carts (id, user_id) VALUES ($1, $2) RETURNING *',
|
|
[uuidv4(), userId]
|
|
);
|
|
}
|
|
|
|
const cartId = cartResult.rows[0].id;
|
|
|
|
// Get cart items with product details
|
|
const cartItemsResult = 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,
|
|
p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
|
|
(
|
|
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.weight_grams, p.length_cm, p.width_cm, p.height_cm`,
|
|
[cartId]
|
|
);
|
|
|
|
// Process images to add primary_image field
|
|
const processedItems = cartItemsResult.rows.map(item => {
|
|
// Add primary_image field derived from images array
|
|
let primaryImage = null;
|
|
if (item.images && item.images.length > 0) {
|
|
primaryImage = item.images.find(img => img.isPrimary === true) || item.images[0];
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
primary_image: primaryImage
|
|
};
|
|
});
|
|
|
|
// Calculate total
|
|
const subtotal = processedItems.reduce((sum, item) => {
|
|
return sum + (parseFloat(item.price) * item.quantity);
|
|
}, 0);
|
|
|
|
// Initialize shipping
|
|
const shipping = {
|
|
rates: []
|
|
};
|
|
|
|
// Calculate basic flat rate shipping
|
|
if (config.shipping.enabled) {
|
|
shipping.rates = await shippingService.getFlatRateShipping(subtotal);
|
|
}
|
|
|
|
res.json({
|
|
id: cartId,
|
|
userId,
|
|
items: processedItems,
|
|
itemCount: processedItems.length,
|
|
subtotal,
|
|
shipping,
|
|
total: subtotal + (shipping.rates.length > 0 ? shipping.rates[0].rate : 0)
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Apply coupon to cart
|
|
* POST /api/cart/apply-coupon
|
|
*/
|
|
router.post('/apply-coupon', async (req, res, next) => {
|
|
try {
|
|
const { userId, code } = req.body;
|
|
|
|
if (req.user.id !== userId) {
|
|
return res.status(403).json({
|
|
error: true,
|
|
message: 'You can only modify your own cart'
|
|
});
|
|
}
|
|
|
|
// Get cart
|
|
const cartResult = await query(
|
|
'SELECT * FROM carts WHERE user_id = $1',
|
|
[userId]
|
|
);
|
|
|
|
if (cartResult.rows.length === 0) {
|
|
return res.status(404).json({
|
|
error: true,
|
|
message: 'Cart not found'
|
|
});
|
|
}
|
|
|
|
const cartId = cartResult.rows[0].id;
|
|
|
|
// Check if coupon code exists and is valid
|
|
const couponResult = await query(`
|
|
SELECT *
|
|
FROM coupons
|
|
WHERE code = $1 AND is_active = true
|
|
`, [code.toUpperCase()]);
|
|
|
|
if (couponResult.rows.length === 0) {
|
|
return res.status(404).json({
|
|
error: true,
|
|
message: 'Invalid coupon code or coupon is inactive'
|
|
});
|
|
}
|
|
|
|
const coupon = couponResult.rows[0];
|
|
|
|
// Check if coupon is expired
|
|
if (coupon.end_date && new Date(coupon.end_date) < new Date()) {
|
|
return res.status(400).json({
|
|
error: true,
|
|
message: 'Coupon has expired'
|
|
});
|
|
}
|
|
|
|
// Check if coupon has not started yet
|
|
if (coupon.start_date && new Date(coupon.start_date) > new Date()) {
|
|
return res.status(400).json({
|
|
error: true,
|
|
message: 'Coupon is not yet active'
|
|
});
|
|
}
|
|
|
|
// Check redemption limit
|
|
if (coupon.redemption_limit !== null && coupon.current_redemptions >= coupon.redemption_limit) {
|
|
return res.status(400).json({
|
|
error: true,
|
|
message: 'Coupon redemption limit has been reached'
|
|
});
|
|
}
|
|
|
|
// Get cart items with product details
|
|
const cartItems = await query(`
|
|
SELECT ci.*, p.id as product_id, p.name, p.price, p.category_id,
|
|
pc.name as category_name,
|
|
(
|
|
SELECT array_agg(t.id)
|
|
FROM product_tags pt
|
|
JOIN tags t ON pt.tag_id = t.id
|
|
WHERE pt.product_id = p.id
|
|
) as tag_ids
|
|
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
|
|
`, [cartId]);
|
|
|
|
if (cartItems.rows.length === 0) {
|
|
return res.status(400).json({
|
|
error: true,
|
|
message: 'Cart is empty'
|
|
});
|
|
}
|
|
|
|
// Calculate subtotal
|
|
const subtotal = cartItems.rows.reduce((sum, item) => {
|
|
return sum + (parseFloat(item.price) * item.quantity);
|
|
}, 0);
|
|
|
|
// Check minimum purchase requirement
|
|
if (coupon.min_purchase_amount && subtotal < coupon.min_purchase_amount) {
|
|
return res.status(400).json({
|
|
error: true,
|
|
message: `Minimum purchase amount of ${coupon.min_purchase_amount.toFixed(2)} not met`,
|
|
minimumAmount: coupon.min_purchase_amount
|
|
});
|
|
}
|
|
|
|
// Get coupon categories
|
|
const couponCategories = await query(`
|
|
SELECT category_id
|
|
FROM coupon_categories
|
|
WHERE coupon_id = $1
|
|
`, [coupon.id]);
|
|
|
|
// Get coupon tags
|
|
const couponTags = await query(`
|
|
SELECT tag_id
|
|
FROM coupon_tags
|
|
WHERE coupon_id = $1
|
|
`, [coupon.id]);
|
|
|
|
// Get blacklisted products
|
|
const blacklistResult = await query(`
|
|
SELECT product_id
|
|
FROM coupon_blacklist
|
|
WHERE coupon_id = $1
|
|
`, [coupon.id]);
|
|
|
|
const blacklistedProductIds = blacklistResult.rows.map(row => row.product_id);
|
|
|
|
// Calculate discount based on eligible products
|
|
let discountableAmount = 0;
|
|
|
|
const categoryIds = couponCategories.rows.map(row => row.category_id);
|
|
const tagIds = couponTags.rows.map(row => row.tag_id);
|
|
|
|
for (const item of cartItems.rows) {
|
|
// Skip blacklisted products
|
|
if (blacklistedProductIds.includes(item.product_id)) {
|
|
continue;
|
|
}
|
|
|
|
let isEligible = false;
|
|
|
|
// If no categories or tags are specified, all products are eligible
|
|
if (categoryIds.length === 0 && tagIds.length === 0) {
|
|
isEligible = true;
|
|
} else {
|
|
// Check if product belongs to eligible category
|
|
if (categoryIds.includes(item.category_id)) {
|
|
isEligible = true;
|
|
}
|
|
|
|
// Check if product has eligible tag
|
|
if (!isEligible && item.tag_ids && item.tag_ids.some(tagId => tagIds.includes(tagId))) {
|
|
isEligible = true;
|
|
}
|
|
}
|
|
|
|
if (isEligible) {
|
|
discountableAmount += parseFloat(item.price) * item.quantity;
|
|
}
|
|
}
|
|
|
|
// Calculate discount amount
|
|
let discountAmount = 0;
|
|
if (coupon.discount_type === 'percentage') {
|
|
discountAmount = discountableAmount * (coupon.discount_value / 100);
|
|
} else { // fixed_amount
|
|
discountAmount = Math.min(discountableAmount, coupon.discount_value);
|
|
}
|
|
|
|
// Apply maximum discount cap if set
|
|
if (coupon.max_discount_amount && discountAmount > coupon.max_discount_amount) {
|
|
discountAmount = coupon.max_discount_amount;
|
|
}
|
|
|
|
// Round to 2 decimal places
|
|
discountAmount = Math.round(discountAmount * 100) / 100;
|
|
|
|
// Update cart metadata with coupon information
|
|
await query(`
|
|
UPDATE carts
|
|
SET metadata = jsonb_set(
|
|
COALESCE(metadata, '{}'::jsonb),
|
|
'{coupon}',
|
|
$1::jsonb
|
|
)
|
|
WHERE id = $2
|
|
`, [
|
|
JSON.stringify({
|
|
id: coupon.id,
|
|
code: coupon.code,
|
|
discount_type: coupon.discount_type,
|
|
discount_value: coupon.discount_value,
|
|
discount_amount: discountAmount
|
|
}),
|
|
cartId
|
|
]);
|
|
|
|
// Get updated cart with all items
|
|
const updatedCartItems = await query(`
|
|
SELECT ci.*, p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
|
|
p.category_id, pc.name AS category_name,
|
|
p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
|
|
(
|
|
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, p.weight_grams, p.length_cm, p.width_cm, p.height_cm
|
|
`, [cartId]);
|
|
|
|
// Get cart metadata with coupon
|
|
const cartMetadata = await query(`
|
|
SELECT metadata
|
|
FROM carts
|
|
WHERE id = $1
|
|
`, [cartId]);
|
|
|
|
// Process images to add primary_image field
|
|
const processedItems = updatedCartItems.rows.map(item => {
|
|
// Add primary_image field derived from images array
|
|
let primaryImage = null;
|
|
if (item.images && item.images.length > 0) {
|
|
primaryImage = item.images.find(img => img.isPrimary === true) || item.images[0];
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
primary_image: primaryImage
|
|
};
|
|
});
|
|
|
|
// Calculate subtotal
|
|
const cartSubtotal = processedItems.reduce((sum, item) => {
|
|
return sum + (parseFloat(item.price) * item.quantity);
|
|
}, 0);
|
|
|
|
// Calculate total with discount
|
|
const total = cartSubtotal - discountAmount;
|
|
|
|
// Format coupon info for response
|
|
const couponInfo = cartMetadata.rows[0].metadata.coupon;
|
|
|
|
res.json({
|
|
id: cartId,
|
|
userId,
|
|
items: processedItems,
|
|
itemCount: processedItems.length,
|
|
subtotal: cartSubtotal,
|
|
couponDiscount: discountAmount,
|
|
couponCode: couponInfo.code,
|
|
couponId: couponInfo.id,
|
|
total: total
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Remove coupon from cart
|
|
* POST /api/cart/remove-coupon
|
|
*/
|
|
router.post('/remove-coupon', async (req, res, next) => {
|
|
try {
|
|
const { userId } = req.body;
|
|
|
|
if (req.user.id !== userId) {
|
|
return res.status(403).json({
|
|
error: true,
|
|
message: 'You can only modify your own cart'
|
|
});
|
|
}
|
|
|
|
// Get cart
|
|
const cartResult = await query(
|
|
'SELECT * FROM carts WHERE user_id = $1',
|
|
[userId]
|
|
);
|
|
|
|
if (cartResult.rows.length === 0) {
|
|
return res.status(404).json({
|
|
error: true,
|
|
message: 'Cart not found'
|
|
});
|
|
}
|
|
|
|
const cartId = cartResult.rows[0].id;
|
|
|
|
// Remove coupon from cart metadata
|
|
await query(`
|
|
UPDATE carts
|
|
SET metadata = metadata - 'coupon'
|
|
WHERE id = $1
|
|
`, [cartId]);
|
|
|
|
// Get updated cart with all items
|
|
const updatedCartItems = await query(`
|
|
SELECT ci.*, p.id AS product_id, p.name, p.description, p.price, p.stock_quantity,
|
|
p.category_id, pc.name AS category_name,
|
|
p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
|
|
(
|
|
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, p.weight_grams, p.length_cm, p.width_cm, p.height_cm
|
|
`, [cartId]);
|
|
|
|
// Process images to add primary_image field
|
|
const processedItems = updatedCartItems.rows.map(item => {
|
|
// Add primary_image field derived from images array
|
|
let primaryImage = null;
|
|
if (item.images && item.images.length > 0) {
|
|
primaryImage = item.images.find(img => img.isPrimary === true) || item.images[0];
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
primary_image: primaryImage
|
|
};
|
|
});
|
|
|
|
// Calculate subtotal
|
|
const subtotal = processedItems.reduce((sum, item) => {
|
|
return sum + (parseFloat(item.price) * item.quantity);
|
|
}, 0);
|
|
|
|
res.json({
|
|
id: cartId,
|
|
userId,
|
|
items: processedItems,
|
|
itemCount: processedItems.length,
|
|
subtotal,
|
|
total: subtotal,
|
|
message: 'Coupon removed successfully'
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
|
|
// Add item to cart
|
|
router.post('/add', async (req, res, next) => {
|
|
try {
|
|
const { userId, productId, quantity = 1 } = req.body;
|
|
if (req.user.id !== userId) {
|
|
return res.status(403).json({
|
|
error: true,
|
|
message: 'You can only modify your own cart'
|
|
});
|
|
}
|
|
// Check if product exists and verify stock
|
|
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];
|
|
|
|
// Get or create cart
|
|
let cartResult = await query(
|
|
'SELECT * FROM carts WHERE user_id = $1',
|
|
[userId]
|
|
);
|
|
|
|
if (cartResult.rows.length === 0) {
|
|
cartResult = await query(
|
|
'INSERT INTO carts (id, user_id) VALUES ($1, $2) RETURNING *',
|
|
[uuidv4(), userId]
|
|
);
|
|
}
|
|
|
|
const cartId = cartResult.rows[0].id;
|
|
|
|
// Check if item already in cart
|
|
const existingItemResult = await query(
|
|
'SELECT * FROM cart_items WHERE cart_id = $1 AND product_id = $2',
|
|
[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
|
|
await query(
|
|
'UPDATE cart_items SET quantity = $1 WHERE id = $2',
|
|
[newQuantity, existingItemResult.rows[0].id]
|
|
);
|
|
} else {
|
|
// Add new item
|
|
await query(
|
|
'INSERT INTO cart_items (id, cart_id, product_id, quantity) VALUES ($1, $2, $3, $4)',
|
|
[uuidv4(), cartId, productId, quantity]
|
|
);
|
|
}
|
|
|
|
// 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.stock_quantity,
|
|
p.category_id, pc.name AS category_name,
|
|
p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
|
|
(
|
|
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, p.weight_grams, p.length_cm, p.width_cm, p.height_cm`,
|
|
[cartId]
|
|
);
|
|
|
|
// Process images to add primary_image field
|
|
const processedItems = updatedCartItems.rows.map(item => {
|
|
// Add primary_image field derived from images array
|
|
let primaryImage = null;
|
|
if (item.images && item.images.length > 0) {
|
|
primaryImage = item.images.find(img => img.isPrimary === true) || item.images[0];
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
primary_image: primaryImage
|
|
};
|
|
});
|
|
|
|
// Calculate subtotal
|
|
const subtotal = processedItems.reduce((sum, item) => {
|
|
return sum + (parseFloat(item.price) * item.quantity);
|
|
}, 0);
|
|
|
|
// Initialize shipping
|
|
const shipping = {
|
|
rates: []
|
|
};
|
|
|
|
// Calculate basic flat rate shipping
|
|
if (config.shipping.enabled) {
|
|
shipping.rates = await shippingService.getFlatRateShipping(subtotal);
|
|
}
|
|
|
|
res.json({
|
|
id: cartId,
|
|
userId,
|
|
items: processedItems,
|
|
itemCount: processedItems.length,
|
|
subtotal,
|
|
shipping,
|
|
total: subtotal + (shipping.rates.length > 0 ? shipping.rates[0].rate : 0)
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// Update cart item quantity
|
|
router.put('/update', async (req, res, next) => {
|
|
try {
|
|
const { userId, productId, quantity } = req.body;
|
|
if (req.user.id !== userId) {
|
|
return res.status(403).json({
|
|
error: true,
|
|
message: 'You can only modify your own cart'
|
|
});
|
|
}
|
|
|
|
// Get cart
|
|
const cartResult = await query(
|
|
'SELECT * FROM carts WHERE user_id = $1',
|
|
[userId]
|
|
);
|
|
|
|
if (cartResult.rows.length === 0) {
|
|
return res.status(404).json({
|
|
error: true,
|
|
message: 'Cart not found'
|
|
});
|
|
}
|
|
|
|
const cartId = cartResult.rows[0].id;
|
|
|
|
if (quantity <= 0) {
|
|
// Remove item
|
|
await query(
|
|
'DELETE FROM cart_items WHERE cart_id = $1 AND product_id = $2',
|
|
[cartId, productId]
|
|
);
|
|
} else {
|
|
// 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]
|
|
);
|
|
}
|
|
|
|
// 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.stock_quantity,
|
|
p.category_id, pc.name AS category_name,
|
|
p.weight_grams, p.length_cm, p.width_cm, p.height_cm,
|
|
(
|
|
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, p.weight_grams, p.length_cm, p.width_cm, p.height_cm`,
|
|
[cartId]
|
|
);
|
|
|
|
// Process images to add primary_image field
|
|
const processedItems = updatedCartItems.rows.map(item => {
|
|
// Add primary_image field derived from images array
|
|
let primaryImage = null;
|
|
if (item.images && item.images.length > 0) {
|
|
primaryImage = item.images.find(img => img.isPrimary === true) || item.images[0];
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
primary_image: primaryImage
|
|
};
|
|
});
|
|
|
|
// Calculate subtotal
|
|
const subtotal = processedItems.reduce((sum, item) => {
|
|
return sum + (parseFloat(item.price) * item.quantity);
|
|
}, 0);
|
|
|
|
// Initialize shipping
|
|
const shipping = {
|
|
rates: []
|
|
};
|
|
|
|
// Calculate basic flat rate shipping
|
|
if (config.shipping.enabled) {
|
|
shipping.rates = await shippingService.getFlatRateShipping(subtotal);
|
|
}
|
|
|
|
res.json({
|
|
id: cartId,
|
|
userId,
|
|
items: processedItems,
|
|
itemCount: processedItems.length,
|
|
subtotal,
|
|
shipping,
|
|
total: subtotal + (shipping.rates.length > 0 ? shipping.rates[0].rate : 0)
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// Clear cart
|
|
router.delete('/clear/:userId', async (req, res, next) => {
|
|
try {
|
|
const { userId } = req.params;
|
|
if (req.user.id !== userId) {
|
|
return res.status(403).json({
|
|
error: true,
|
|
message: 'You can only modify your own cart'
|
|
});
|
|
}
|
|
// Get cart
|
|
const cartResult = await query(
|
|
'SELECT * FROM carts WHERE user_id = $1',
|
|
[userId]
|
|
);
|
|
|
|
if (cartResult.rows.length === 0) {
|
|
return res.status(404).json({
|
|
error: true,
|
|
message: 'Cart not found'
|
|
});
|
|
}
|
|
|
|
const cartId = cartResult.rows[0].id;
|
|
|
|
// Delete all items
|
|
await query(
|
|
'DELETE FROM cart_items WHERE cart_id = $1',
|
|
[cartId]
|
|
);
|
|
|
|
res.json({
|
|
id: cartId,
|
|
userId,
|
|
items: [],
|
|
itemCount: 0,
|
|
subtotal: 0,
|
|
shipping: {
|
|
rates: []
|
|
},
|
|
total: 0
|
|
});
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// Get shipping rates for current cart
|
|
router.post('/shipping-rates', async (req, res, next) => {
|
|
try {
|
|
const { userId, shippingAddress } = req.body;
|
|
|
|
if (req.user.id !== userId) {
|
|
return res.status(403).json({
|
|
error: true,
|
|
message: 'You can only get shipping rates for your own cart'
|
|
});
|
|
}
|
|
|
|
// Shipping must be enabled
|
|
if (!config.shipping.enabled) {
|
|
return res.status(400).json({
|
|
error: true,
|
|
message: 'Shipping is currently disabled'
|
|
});
|
|
}
|
|
|
|
// Get cart
|
|
const cartResult = await query(
|
|
'SELECT * FROM carts WHERE user_id = $1',
|
|
[userId]
|
|
);
|
|
|
|
if (cartResult.rows.length === 0) {
|
|
return res.status(404).json({
|
|
error: true,
|
|
message: 'Cart not found'
|
|
});
|
|
}
|
|
|
|
const cartId = cartResult.rows[0].id;
|
|
|
|
// Get cart items with product weights
|
|
const cartItemsResult = await query(
|
|
`SELECT ci.quantity, p.id, p.weight_grams, p.price,
|
|
p.length_cm, p.width_cm, p.height_cm
|
|
FROM cart_items ci
|
|
JOIN products p ON ci.product_id = p.id
|
|
WHERE ci.cart_id = $1`,
|
|
[cartId]
|
|
);
|
|
|
|
if (cartItemsResult.rows.length === 0) {
|
|
return res.status(400).json({
|
|
error: true,
|
|
message: 'Cart is empty'
|
|
});
|
|
}
|
|
|
|
// Calculate total weight and order value
|
|
const totalWeight = shippingService.calculateTotalWeight(cartItemsResult.rows);
|
|
const subtotal = cartItemsResult.rows.reduce((sum, item) => {
|
|
return sum + (parseFloat(item.price) * item.quantity);
|
|
}, 0);
|
|
|
|
// If no address provided, return only flat rate shipping
|
|
if (!shippingAddress) {
|
|
console.log("No Address provided - using flat rate");
|
|
const rates = shippingService.getFlatRateShipping(subtotal);
|
|
|
|
return res.json({
|
|
success: true,
|
|
shipment_id: null,
|
|
rates
|
|
});
|
|
}
|
|
|
|
// Get real shipping rates with a shipment
|
|
const parsedAddress = typeof shippingAddress === 'string'
|
|
? shippingService.parseAddressString(shippingAddress)
|
|
: shippingAddress;
|
|
|
|
|
|
const shippingResponse = await shippingService.getShippingRates(
|
|
null, // Use default from config
|
|
parsedAddress,
|
|
{
|
|
weight: totalWeight,
|
|
length: Math.max(...cartItemsResult.rows.map(item => item.length_cm || 0)),
|
|
width: Math.max(...cartItemsResult.rows.map(item => item.width_cm || 0)),
|
|
height: Math.max(...cartItemsResult.rows.map(item => item.height_cm || 0)),
|
|
order_total: subtotal
|
|
}
|
|
);
|
|
|
|
console.log("Shipping rates response:", JSON.stringify(shippingResponse, null, 4));
|
|
|
|
// Save the shipment ID to the session or temporary storage
|
|
// This will be used when the user selects a rate
|
|
if (shippingResponse.shipment_id) {
|
|
// Store the shipment ID in the user's cart for later use
|
|
await query(
|
|
`UPDATE carts SET metadata = jsonb_set(
|
|
COALESCE(metadata, '{}'::jsonb),
|
|
'{temp_shipment_id}',
|
|
$1::jsonb
|
|
) WHERE id = $2`,
|
|
[JSON.stringify(shippingResponse.shipment_id), cartId]
|
|
);
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
shipment_id: shippingResponse.shipment_id,
|
|
rates: shippingResponse.rates
|
|
});
|
|
} catch (error) {
|
|
console.error('Error getting shipping rates:', error);
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// Complete checkout after successful payment
|
|
router.post('/complete-checkout', async (req, res, next) => {
|
|
try {
|
|
const { userId, orderId, sessionId } = req.body;
|
|
console.log("Complete Checkout ", `${userId} ${orderId} ${sessionId}`)
|
|
|
|
if (req.user.id !== userId) {
|
|
return res.status(403).json({
|
|
error: true,
|
|
message: 'You can only complete your own checkout'
|
|
});
|
|
}
|
|
|
|
// Verify the order exists and belongs to the user
|
|
const orderResult = await query(
|
|
'SELECT * FROM orders WHERE id = $1 AND user_id = $2',
|
|
[orderId, userId]
|
|
);
|
|
|
|
if (orderResult.rows.length === 0) {
|
|
return res.status(404).json({
|
|
error: true,
|
|
message: 'Order not found'
|
|
});
|
|
}
|
|
|
|
const order = orderResult.rows[0];
|
|
|
|
// Begin transaction
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Update order status and payment info
|
|
await client.query(
|
|
'UPDATE orders SET status = $1, payment_completed = true, payment_id = $2 WHERE id = $3',
|
|
['processing', sessionId, orderId]
|
|
);
|
|
|
|
// Get cart
|
|
const cartResult = await client.query(
|
|
'SELECT * FROM carts WHERE user_id = $1',
|
|
[userId]
|
|
);
|
|
|
|
if (cartResult.rows.length > 0) {
|
|
const cartId = cartResult.rows[0].id;
|
|
|
|
// Check if cart has a coupon applied
|
|
const cartMetadata = cartResult.rows[0].metadata;
|
|
if (cartMetadata && cartMetadata.coupon) {
|
|
const couponInfo = cartMetadata.coupon;
|
|
|
|
// Increment the coupon's current_redemptions
|
|
await client.query(
|
|
'UPDATE coupons SET current_redemptions = current_redemptions + 1 WHERE id = $1',
|
|
[couponInfo.id]
|
|
);
|
|
|
|
// Create a coupon redemption record
|
|
await client.query(
|
|
'INSERT INTO coupon_redemptions (coupon_id, order_id, user_id, discount_amount) VALUES ($1, $2, $3, $4)',
|
|
[couponInfo.id, orderId, userId, couponInfo.discount_amount]
|
|
);
|
|
|
|
// Update the order with coupon information
|
|
await client.query(
|
|
'UPDATE orders SET coupon_id = $1, discount_amount = $2 WHERE id = $3',
|
|
[couponInfo.id, couponInfo.discount_amount, orderId]
|
|
);
|
|
}
|
|
|
|
// Get cart items to update product stock
|
|
const cartItemsResult = await client.query(
|
|
'SELECT * FROM cart_items WHERE cart_id = $1',
|
|
[cartId]
|
|
);
|
|
|
|
// Update product stock
|
|
for (const item of cartItemsResult.rows) {
|
|
await client.query(
|
|
'UPDATE products SET stock_quantity = stock_quantity - $1 WHERE id = $2',
|
|
[item.quantity, item.product_id]
|
|
);
|
|
|
|
// Process stock notifications after updating stock
|
|
try {
|
|
const productWithNotification = await client.query(
|
|
`SELECT id, name, stock_quantity, stock_notification
|
|
FROM products
|
|
WHERE id = $1`,
|
|
[item.product_id]
|
|
);
|
|
|
|
if (productWithNotification.rows.length > 0) {
|
|
const product = productWithNotification.rows[0];
|
|
let stockNotification;
|
|
|
|
// Handle different ways the JSON could be stored
|
|
if (product.stock_notification) {
|
|
try {
|
|
// If it's a string, parse it
|
|
// why are we doing this its been an obj for months now
|
|
if (typeof product.stock_notification === 'string') {
|
|
stockNotification = JSON.parse(product.stock_notification);
|
|
} else {
|
|
// Otherwise use as is
|
|
stockNotification = product.stock_notification;
|
|
}
|
|
|
|
console.log("Stock notification for product:", product.id, stockNotification);
|
|
|
|
// Check if notification is enabled and stock is below threshold
|
|
if (stockNotification &&
|
|
stockNotification.enabled === true &&
|
|
stockNotification.email &&
|
|
stockNotification.threshold &&
|
|
product.stock_quantity <= parseInt(stockNotification.threshold)) {
|
|
|
|
// Log the notification with the order ID
|
|
await client.query(
|
|
`INSERT INTO notification_logs
|
|
(order_id, notification_type, sent_at, status)
|
|
VALUES ($1, $2, NOW(), $3)`,
|
|
[orderId, 'low_stock_alert', 'pending']
|
|
);
|
|
|
|
console.log(`Low stock notification queued for product ${product.id} - ${product.name}`);
|
|
}
|
|
} catch (parseError) {
|
|
console.error("Error parsing stock notification JSON:", parseError);
|
|
}
|
|
}
|
|
}
|
|
} catch (notificationError) {
|
|
console.error("Error processing stock notification:", notificationError);
|
|
// Continue with checkout even if notification processing fails
|
|
}
|
|
}
|
|
|
|
// Clear cart
|
|
await client.query(
|
|
'DELETE FROM cart_items WHERE cart_id = $1',
|
|
[cartId]
|
|
);
|
|
|
|
// Clear cart metadata (including coupon)
|
|
await client.query(
|
|
'UPDATE carts SET metadata = $1 WHERE id = $2',
|
|
['{}', cartId]
|
|
);
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
|
|
res.status(200).json({
|
|
success: true,
|
|
message: 'Order completed successfully',
|
|
orderId
|
|
});
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
// Complete checkout after successful payment
|
|
router.post('/complete-checkout', async (req, res, next) => {
|
|
try {
|
|
const { userId, orderId, sessionId } = req.body;
|
|
console.log("Complete Checkout ", `${userId} ${orderId} ${sessionId}`)
|
|
|
|
if (req.user.id !== userId) {
|
|
return res.status(403).json({
|
|
error: true,
|
|
message: 'You can only complete your own checkout'
|
|
});
|
|
}
|
|
|
|
// Verify the order exists and belongs to the user
|
|
const orderResult = await query(
|
|
'SELECT * FROM orders WHERE id = $1 AND user_id = $2',
|
|
[orderId, userId]
|
|
);
|
|
|
|
if (orderResult.rows.length === 0) {
|
|
return res.status(404).json({
|
|
error: true,
|
|
message: 'Order not found'
|
|
});
|
|
}
|
|
|
|
// Begin transaction
|
|
const client = await pool.connect();
|
|
|
|
try {
|
|
await client.query('BEGIN');
|
|
|
|
// Update order status and payment info
|
|
await client.query(
|
|
'UPDATE orders SET status = $1, payment_completed = true, payment_id = $2 WHERE id = $3',
|
|
['processing', sessionId, orderId]
|
|
);
|
|
|
|
// Get cart
|
|
const cartResult = await client.query(
|
|
'SELECT * FROM carts WHERE user_id = $1',
|
|
[userId]
|
|
);
|
|
|
|
if (cartResult.rows.length > 0) {
|
|
const cartId = cartResult.rows[0].id;
|
|
|
|
// Get cart items to update product stock
|
|
const cartItemsResult = await client.query(
|
|
'SELECT * FROM cart_items WHERE cart_id = $1',
|
|
[cartId]
|
|
);
|
|
|
|
// Update product stock
|
|
for (const item of cartItemsResult.rows) {
|
|
await client.query(
|
|
'UPDATE products SET stock_quantity = stock_quantity - $1 WHERE id = $2',
|
|
[item.quantity, item.product_id]
|
|
);
|
|
|
|
// Process stock notifications after updating stock
|
|
try {
|
|
const productWithNotification = await client.query(
|
|
`SELECT id, name, stock_quantity, stock_notification
|
|
FROM products
|
|
WHERE id = $1`,
|
|
[item.product_id]
|
|
);
|
|
|
|
if (productWithNotification.rows.length > 0) {
|
|
const product = productWithNotification.rows[0];
|
|
let stockNotification;
|
|
|
|
// Handle different ways the JSON could be stored
|
|
if (product.stock_notification) {
|
|
try {
|
|
// If it's a string, parse it
|
|
// why are we doing this its been an obj for months now
|
|
if (typeof product.stock_notification === 'string') {
|
|
stockNotification = JSON.parse(product.stock_notification);
|
|
} else {
|
|
// Otherwise use as is
|
|
stockNotification = product.stock_notification;
|
|
}
|
|
|
|
console.log("Stock notification for product:", product.id, stockNotification);
|
|
|
|
// Check if notification is enabled and stock is below threshold
|
|
if (stockNotification &&
|
|
stockNotification.enabled === true &&
|
|
stockNotification.email &&
|
|
stockNotification.threshold &&
|
|
product.stock_quantity <= parseInt(stockNotification.threshold)) {
|
|
|
|
// Log the notification with the order ID
|
|
await client.query(
|
|
`INSERT INTO notification_logs
|
|
(order_id, notification_type, sent_at, status)
|
|
VALUES ($1, $2, NOW(), $3)`,
|
|
[orderId, 'low_stock_alert', 'pending']
|
|
);
|
|
|
|
console.log(`Low stock notification queued for product ${product.id} - ${product.name}`);
|
|
}
|
|
} catch (parseError) {
|
|
console.error("Error parsing stock notification JSON:", parseError);
|
|
}
|
|
}
|
|
}
|
|
} catch (notificationError) {
|
|
console.error("Error processing stock notification:", notificationError);
|
|
// Continue with checkout even if notification processing fails
|
|
}
|
|
}
|
|
|
|
// Clear cart
|
|
await client.query(
|
|
'DELETE FROM cart_items WHERE cart_id = $1',
|
|
[cartId]
|
|
);
|
|
}
|
|
|
|
await client.query('COMMIT');
|
|
|
|
res.status(200).json({
|
|
success: true,
|
|
message: 'Order completed successfully',
|
|
orderId
|
|
});
|
|
} catch (error) {
|
|
await client.query('ROLLBACK');
|
|
throw error;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
|
|
return router;
|
|
}; |