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++; const searchParam1 = paramCount; paramCount++; const searchParam2 = paramCount; paramCount++; const searchParam3 = paramCount; whereConditions.push(`(p.name ILIKE $${searchParam1} OR p.brand ILIKE $${searchParam2} OR p.active_ingredients ILIKE $${searchParam3})`); queryParams.push(`%${search}%`, `%${search}%`, `%${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 as base_brand, p.product_type as base_product_type, pc.name as category_name, upc.name as custom_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 LEFT JOIN product_categories upc ON up.category_id = upc.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, // Use custom fields if available, otherwise fall back to base product brand: product.custom_brand || product.base_brand, customBrand: product.custom_brand, categoryId: product.category_id, categoryName: product.custom_category_name || product.category_name, productType: product.custom_product_type || product.base_product_type || null, customProductType: product.custom_product_type, activeIngredients: product.custom_active_ingredients, customActiveIngredients: product.custom_active_ingredients, description: product.custom_description, customDescription: product.custom_description, customRateAmount: parseFloat(product.custom_rate_amount), customRateUnit: product.custom_rate_unit, notes: product.notes, isShared: false, createdAt: product.created_at, updatedAt: product.updated_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, // Advanced fields for creating custom products brand, categoryId, productType, activeIngredients, description, spreaderSettings } = 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_brand, category_id, custom_product_type, custom_active_ingredients, custom_description, custom_rate_amount, custom_rate_unit, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`, [req.user.id, productId, customName, brand, categoryId, productType, activeIngredients, description, customRateAmount, customRateUnit, notes] ); const userProduct = result.rows[0]; // Handle spreader settings for granular products if (spreaderSettings && Array.isArray(spreaderSettings) && productType === 'granular') { // Add spreader settings for this user product for (const setting of spreaderSettings) { // Check if equipment exists and belongs to user if equipmentId is provided if (setting.equipmentId) { const equipmentCheck = await pool.query( 'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2', [setting.equipmentId, req.user.id] ); if (equipmentCheck.rows.length === 0) { throw new AppError(`Equipment with id ${setting.equipmentId} not found`, 404); } await pool.query( `INSERT INTO product_spreader_settings (user_product_id, equipment_id, setting_value, rate_description, notes) VALUES ($1, $2, $3, $4, $5)`, [userProduct.id, setting.equipmentId, setting.settingValue, setting.rateDescription, setting.notes] ); } else { // Fall back to legacy brand/model approach await pool.query( `INSERT INTO product_spreader_settings (user_product_id, spreader_brand, spreader_model, setting_value, rate_description, notes) VALUES ($1, $2, $3, $4, $5, $6)`, [userProduct.id, setting.spreaderBrand, setting.spreaderModel, setting.settingValue, setting.rateDescription, setting.notes] ); } } } res.status(201).json({ success: true, message: 'Custom product created successfully', data: { userProduct: { id: userProduct.id, baseProductId: userProduct.product_id, customName: userProduct.custom_name, customBrand: userProduct.custom_brand, categoryId: userProduct.category_id, customProductType: userProduct.custom_product_type, customActiveIngredients: userProduct.custom_active_ingredients, customDescription: userProduct.custom_description, 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 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 as base_brand, p.product_type as base_product_type, p.active_ingredients as base_active_ingredients, pc.name as base_category_name, upc.name as custom_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 LEFT JOIN product_categories upc ON up.category_id = upc.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]; // Get spreader settings for this user product with equipment details let spreaderSettings = []; const settingsResult = await pool.query( `SELECT pss.*, ue.custom_name as equipment_name, ue.manufacturer, ue.model as equipment_model, ue.spreader_type, ue.capacity_lbs FROM product_spreader_settings pss LEFT JOIN user_equipment ue ON pss.equipment_id = ue.id WHERE pss.user_product_id = $1 ORDER BY ue.custom_name NULLS LAST, pss.spreader_brand, pss.spreader_model NULLS LAST, pss.setting_value`, [userProductId] ); spreaderSettings = settingsResult.rows.map(row => ({ id: row.id, equipmentId: row.equipment_id, equipmentName: row.equipment_name, equipmentManufacturer: row.manufacturer, equipmentModel: row.equipment_model, equipmentType: row.spreader_type, equipmentCapacity: row.capacity_lbs ? parseFloat(row.capacity_lbs) : null, // Legacy fields spreaderBrand: row.spreader_brand, spreaderModel: row.spreader_model, settingValue: row.setting_value, rateDescription: row.rate_description, notes: row.notes, createdAt: row.created_at })); res.json({ success: true, data: { userProduct: { id: userProduct.id, baseProductId: userProduct.product_id, baseProductName: userProduct.base_product_name, customName: userProduct.custom_name, brand: userProduct.custom_brand || userProduct.base_brand, categoryName: userProduct.custom_category_name || userProduct.base_category_name, categoryId: userProduct.category_id || (userProduct.product_id ? userProduct.category_id : null), productType: userProduct.custom_product_type || userProduct.base_product_type, activeIngredients: userProduct.custom_active_ingredients || userProduct.base_active_ingredients, description: userProduct.custom_description, customRateAmount: userProduct.custom_rate_amount ? parseFloat(userProduct.custom_rate_amount) : null, customRateUnit: userProduct.custom_rate_unit, notes: userProduct.notes, spreaderSettings: spreaderSettings, createdAt: userProduct.created_at, updatedAt: userProduct.updated_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, isAdvancedEdit, // Advanced edit fields brand, categoryId, productType, activeIngredients, description, spreaderSettings } = 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_brand = $3, category_id = $4, custom_product_type = $5, custom_active_ingredients = $6, custom_description = $7, custom_rate_amount = $8, custom_rate_unit = $9, notes = $10, updated_at = CURRENT_TIMESTAMP WHERE id = $11 RETURNING *`, [ productId, customName, brand, categoryId, productType, activeIngredients, description, customRateAmount, customRateUnit, notes, userProductId ] ); const userProduct = result.rows[0]; // Handle spreader settings for granular products if (spreaderSettings && Array.isArray(spreaderSettings) && productType === 'granular') { // First, delete existing spreader settings for this user product await pool.query( 'DELETE FROM product_spreader_settings WHERE user_product_id = $1', [userProductId] ); // Then add the new settings for (const setting of spreaderSettings) { // Check if equipment exists and belongs to user if equipmentId is provided if (setting.equipmentId) { const equipmentCheck = await pool.query( 'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2', [setting.equipmentId, req.user.id] ); if (equipmentCheck.rows.length === 0) { throw new AppError(`Equipment with id ${setting.equipmentId} not found`, 404); } await pool.query( `INSERT INTO product_spreader_settings (user_product_id, equipment_id, setting_value, rate_description, notes) VALUES ($1, $2, $3, $4, $5)`, [userProductId, setting.equipmentId, setting.settingValue, setting.rateDescription, setting.notes] ); } else { // Fall back to legacy brand/model approach await pool.query( `INSERT INTO product_spreader_settings (user_product_id, spreader_brand, spreader_model, setting_value, rate_description, notes) VALUES ($1, $2, $3, $4, $5, $6)`, [ userProductId, setting.spreaderBrand, setting.spreaderModel, setting.settingValue, setting.rateDescription, setting.notes ] ); } } } res.json({ success: true, message: 'Custom product updated successfully', data: { userProduct: { id: userProduct.id, baseProductId: userProduct.product_id, customName: userProduct.custom_name, customBrand: userProduct.custom_brand, categoryId: userProduct.category_id, customProductType: userProduct.custom_product_type, customActiveIngredients: userProduct.custom_active_ingredients, customDescription: userProduct.custom_description, 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;