Initial Claude Run
This commit is contained in:
537
backend/src/routes/products.js
Normal file
537
backend/src/routes/products.js
Normal file
@@ -0,0 +1,537 @@
|
||||
const express = require('express');
|
||||
const pool = require('../config/database');
|
||||
const { validateRequest, validateParams } = require('../utils/validation');
|
||||
const { productSchema, productRateSchema, userProductSchema, idParamSchema } = require('../utils/validation');
|
||||
const { AppError } = require('../middleware/errorHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// @route GET /api/products/categories
|
||||
// @desc Get all product categories
|
||||
// @access Private
|
||||
router.get('/categories', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM product_categories ORDER BY name'
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
categories: result.rows
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/products
|
||||
// @desc Get all products (shared + user's custom products)
|
||||
// @access Private
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { category, type, search } = req.query;
|
||||
|
||||
let whereConditions = [];
|
||||
let queryParams = [req.user.id];
|
||||
let paramCount = 1;
|
||||
|
||||
// Build WHERE clause for filtering
|
||||
if (category) {
|
||||
paramCount++;
|
||||
whereConditions.push(`pc.id = $${paramCount}`);
|
||||
queryParams.push(category);
|
||||
}
|
||||
|
||||
if (type) {
|
||||
paramCount++;
|
||||
whereConditions.push(`p.product_type = $${paramCount}`);
|
||||
queryParams.push(type);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
paramCount++;
|
||||
whereConditions.push(`(p.name ILIKE $${paramCount} OR p.brand ILIKE $${paramCount} OR p.active_ingredients ILIKE $${paramCount})`);
|
||||
queryParams.push(`%${search}%`);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0 ? `AND ${whereConditions.join(' AND ')}` : '';
|
||||
|
||||
// Get shared products
|
||||
const sharedProductsQuery = `
|
||||
SELECT p.*, pc.name as category_name,
|
||||
array_agg(
|
||||
json_build_object(
|
||||
'id', pr.id,
|
||||
'applicationType', pr.application_type,
|
||||
'rateAmount', pr.rate_amount,
|
||||
'rateUnit', pr.rate_unit,
|
||||
'notes', pr.notes
|
||||
)
|
||||
) FILTER (WHERE pr.id IS NOT NULL) as rates
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
LEFT JOIN product_rates pr ON p.id = pr.product_id
|
||||
WHERE 1=1 ${whereClause}
|
||||
GROUP BY p.id, pc.name
|
||||
ORDER BY p.name
|
||||
`;
|
||||
|
||||
const sharedResult = await pool.query(sharedProductsQuery, queryParams.slice(1));
|
||||
|
||||
// Get user's custom products
|
||||
const userProductsQuery = `
|
||||
SELECT up.*, p.name as base_product_name, p.brand, p.product_type, pc.name as category_name
|
||||
FROM user_products up
|
||||
LEFT JOIN products p ON up.product_id = p.id
|
||||
LEFT JOIN product_categories pc ON p.category_id = pc.id
|
||||
WHERE up.user_id = $1
|
||||
ORDER BY COALESCE(up.custom_name, p.name)
|
||||
`;
|
||||
|
||||
const userResult = await pool.query(userProductsQuery, [req.user.id]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
sharedProducts: sharedResult.rows.map(product => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
categoryName: product.category_name,
|
||||
productType: product.product_type,
|
||||
activeIngredients: product.active_ingredients,
|
||||
description: product.description,
|
||||
rates: product.rates || [],
|
||||
isShared: true,
|
||||
createdAt: product.created_at
|
||||
})),
|
||||
userProducts: userResult.rows.map(product => ({
|
||||
id: product.id,
|
||||
baseProductId: product.product_id,
|
||||
baseProductName: product.base_product_name,
|
||||
customName: product.custom_name,
|
||||
brand: product.brand,
|
||||
categoryName: product.category_name,
|
||||
productType: product.product_type,
|
||||
customRateAmount: parseFloat(product.custom_rate_amount),
|
||||
customRateUnit: product.custom_rate_unit,
|
||||
notes: product.notes,
|
||||
isShared: false,
|
||||
createdAt: product.created_at
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/products/:id
|
||||
// @desc Get single shared product with rates
|
||||
// @access Private
|
||||
router.get('/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const productId = req.params.id;
|
||||
|
||||
const productResult = await pool.query(
|
||||
`SELECT p.*, pc.name as category_name
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
WHERE p.id = $1`,
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (productResult.rows.length === 0) {
|
||||
throw new AppError('Product not found', 404);
|
||||
}
|
||||
|
||||
const product = productResult.rows[0];
|
||||
|
||||
// Get product rates
|
||||
const ratesResult = await pool.query(
|
||||
'SELECT * FROM product_rates WHERE product_id = $1 ORDER BY application_type',
|
||||
[productId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
product: {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
categoryName: product.category_name,
|
||||
productType: product.product_type,
|
||||
activeIngredients: product.active_ingredients,
|
||||
description: product.description,
|
||||
rates: ratesResult.rows.map(rate => ({
|
||||
id: rate.id,
|
||||
applicationType: rate.application_type,
|
||||
rateAmount: parseFloat(rate.rate_amount),
|
||||
rateUnit: rate.rate_unit,
|
||||
notes: rate.notes,
|
||||
createdAt: rate.created_at
|
||||
})),
|
||||
createdAt: product.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/products
|
||||
// @desc Create new shared product (admin only)
|
||||
// @access Private (Admin)
|
||||
router.post('/', validateRequest(productSchema), async (req, res, next) => {
|
||||
try {
|
||||
// Check if user is admin
|
||||
if (req.user.role !== 'admin') {
|
||||
throw new AppError('Admin access required', 403);
|
||||
}
|
||||
|
||||
const { name, brand, categoryId, productType, activeIngredients, description } = req.body;
|
||||
|
||||
// Check if category exists
|
||||
const categoryCheck = await pool.query(
|
||||
'SELECT id FROM product_categories WHERE id = $1',
|
||||
[categoryId]
|
||||
);
|
||||
|
||||
if (categoryCheck.rows.length === 0) {
|
||||
throw new AppError('Product category not found', 404);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO products (name, brand, category_id, product_type, active_ingredients, description)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[name, brand, categoryId, productType, activeIngredients, description]
|
||||
);
|
||||
|
||||
const product = result.rows[0];
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Product created successfully',
|
||||
data: {
|
||||
product: {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
categoryId: product.category_id,
|
||||
productType: product.product_type,
|
||||
activeIngredients: product.active_ingredients,
|
||||
description: product.description,
|
||||
createdAt: product.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/products/:id/rates
|
||||
// @desc Add application rate to product (admin only)
|
||||
// @access Private (Admin)
|
||||
router.post('/:id/rates', validateParams(idParamSchema), validateRequest(productRateSchema), async (req, res, next) => {
|
||||
try {
|
||||
// Check if user is admin
|
||||
if (req.user.role !== 'admin') {
|
||||
throw new AppError('Admin access required', 403);
|
||||
}
|
||||
|
||||
const productId = req.params.id;
|
||||
const { applicationType, rateAmount, rateUnit, notes } = req.body;
|
||||
|
||||
// Check if product exists
|
||||
const productCheck = await pool.query(
|
||||
'SELECT id FROM products WHERE id = $1',
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (productCheck.rows.length === 0) {
|
||||
throw new AppError('Product not found', 404);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO product_rates (product_id, application_type, rate_amount, rate_unit, notes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[productId, applicationType, rateAmount, rateUnit, notes]
|
||||
);
|
||||
|
||||
const rate = result.rows[0];
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Application rate added successfully',
|
||||
data: {
|
||||
rate: {
|
||||
id: rate.id,
|
||||
productId: rate.product_id,
|
||||
applicationType: rate.application_type,
|
||||
rateAmount: parseFloat(rate.rate_amount),
|
||||
rateUnit: rate.rate_unit,
|
||||
notes: rate.notes,
|
||||
createdAt: rate.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/products/user
|
||||
// @desc Create user's custom product
|
||||
// @access Private
|
||||
router.post('/user', validateRequest(userProductSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { productId, customName, customRateAmount, customRateUnit, notes } = req.body;
|
||||
|
||||
// If based on existing product, verify it exists
|
||||
if (productId) {
|
||||
const productCheck = await pool.query(
|
||||
'SELECT id FROM products WHERE id = $1',
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (productCheck.rows.length === 0) {
|
||||
throw new AppError('Base product not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
// Require either productId or customName
|
||||
if (!productId && !customName) {
|
||||
throw new AppError('Either base product or custom name is required', 400);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO user_products (user_id, product_id, custom_name, custom_rate_amount, custom_rate_unit, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[req.user.id, productId, customName, customRateAmount, customRateUnit, notes]
|
||||
);
|
||||
|
||||
const userProduct = result.rows[0];
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Custom product created successfully',
|
||||
data: {
|
||||
userProduct: {
|
||||
id: userProduct.id,
|
||||
baseProductId: userProduct.product_id,
|
||||
customName: userProduct.custom_name,
|
||||
customRateAmount: parseFloat(userProduct.custom_rate_amount),
|
||||
customRateUnit: userProduct.custom_rate_unit,
|
||||
notes: userProduct.notes,
|
||||
createdAt: userProduct.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/products/user/:id
|
||||
// @desc Get user's custom product
|
||||
// @access Private
|
||||
router.get('/user/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const userProductId = req.params.id;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT up.*, p.name as base_product_name, p.brand, p.product_type,
|
||||
p.active_ingredients, pc.name as category_name
|
||||
FROM user_products up
|
||||
LEFT JOIN products p ON up.product_id = p.id
|
||||
LEFT JOIN product_categories pc ON p.category_id = pc.id
|
||||
WHERE up.id = $1 AND up.user_id = $2`,
|
||||
[userProductId, req.user.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new AppError('User product not found', 404);
|
||||
}
|
||||
|
||||
const userProduct = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userProduct: {
|
||||
id: userProduct.id,
|
||||
baseProductId: userProduct.product_id,
|
||||
baseProductName: userProduct.base_product_name,
|
||||
customName: userProduct.custom_name,
|
||||
brand: userProduct.brand,
|
||||
categoryName: userProduct.category_name,
|
||||
productType: userProduct.product_type,
|
||||
activeIngredients: userProduct.active_ingredients,
|
||||
customRateAmount: parseFloat(userProduct.custom_rate_amount),
|
||||
customRateUnit: userProduct.custom_rate_unit,
|
||||
notes: userProduct.notes,
|
||||
createdAt: userProduct.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/products/user/:id
|
||||
// @desc Update user's custom product
|
||||
// @access Private
|
||||
router.put('/user/:id', validateParams(idParamSchema), validateRequest(userProductSchema), async (req, res, next) => {
|
||||
try {
|
||||
const userProductId = req.params.id;
|
||||
const { productId, customName, customRateAmount, customRateUnit, notes } = req.body;
|
||||
|
||||
// Check if user product exists and belongs to user
|
||||
const checkResult = await pool.query(
|
||||
'SELECT id FROM user_products WHERE id = $1 AND user_id = $2',
|
||||
[userProductId, req.user.id]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length === 0) {
|
||||
throw new AppError('User product not found', 404);
|
||||
}
|
||||
|
||||
// If changing base product, verify it exists
|
||||
if (productId) {
|
||||
const productCheck = await pool.query(
|
||||
'SELECT id FROM products WHERE id = $1',
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (productCheck.rows.length === 0) {
|
||||
throw new AppError('Base product not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE user_products
|
||||
SET product_id = $1, custom_name = $2, custom_rate_amount = $3,
|
||||
custom_rate_unit = $4, notes = $5, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $6
|
||||
RETURNING *`,
|
||||
[productId, customName, customRateAmount, customRateUnit, notes, userProductId]
|
||||
);
|
||||
|
||||
const userProduct = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Custom product updated successfully',
|
||||
data: {
|
||||
userProduct: {
|
||||
id: userProduct.id,
|
||||
baseProductId: userProduct.product_id,
|
||||
customName: userProduct.custom_name,
|
||||
customRateAmount: parseFloat(userProduct.custom_rate_amount),
|
||||
customRateUnit: userProduct.custom_rate_unit,
|
||||
notes: userProduct.notes,
|
||||
createdAt: userProduct.created_at,
|
||||
updatedAt: userProduct.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/products/user/:id
|
||||
// @desc Delete user's custom product
|
||||
// @access Private
|
||||
router.delete('/user/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const userProductId = req.params.id;
|
||||
|
||||
// Check if user product exists and belongs to user
|
||||
const checkResult = await pool.query(
|
||||
'SELECT id FROM user_products WHERE id = $1 AND user_id = $2',
|
||||
[userProductId, req.user.id]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length === 0) {
|
||||
throw new AppError('User product not found', 404);
|
||||
}
|
||||
|
||||
// Check if product is used in any applications
|
||||
const usageCheck = await pool.query(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM application_plan_products
|
||||
WHERE user_product_id = $1`,
|
||||
[userProductId]
|
||||
);
|
||||
|
||||
if (parseInt(usageCheck.rows[0].count) > 0) {
|
||||
throw new AppError('Cannot delete product that has been used in applications', 400);
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM user_products WHERE id = $1', [userProductId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Custom product deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/products/search
|
||||
// @desc Search products by name or ingredients
|
||||
// @access Private
|
||||
router.get('/search', async (req, res, next) => {
|
||||
try {
|
||||
const { q, limit = 10 } = req.query;
|
||||
|
||||
if (!q || q.length < 2) {
|
||||
throw new AppError('Search query must be at least 2 characters', 400);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT p.*, pc.name as category_name
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
WHERE p.name ILIKE $1 OR p.brand ILIKE $1 OR p.active_ingredients ILIKE $1
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN p.name ILIKE $2 THEN 1
|
||||
WHEN p.brand ILIKE $2 THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
p.name
|
||||
LIMIT $3`,
|
||||
[`%${q}%`, `${q}%`, limit]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
products: result.rows.map(product => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
categoryName: product.category_name,
|
||||
productType: product.product_type,
|
||||
activeIngredients: product.active_ingredients
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
Reference in New Issue
Block a user