Files
turftracker/backend/src/routes/products.js
Jake Kasper fefbc4f053 fix products
2025-08-28 14:12:23 -04:00

724 lines
23 KiB
JavaScript

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;