const express = require('express'); const pool = require('../config/database'); const { validateRequest, validateParams } = require('../utils/validation'); const { idParamSchema } = require('../utils/validation'); const { AppError } = require('../middleware/errorHandler'); const router = express.Router(); // @route GET /api/nozzles/types // @desc Get all nozzle types with flow rate data // @access Private router.get('/types', async (req, res, next) => { try { const { manufacturer, droplet_size, spray_pattern, orifice_size } = req.query; let query = ` SELECT nt.*, json_agg( json_build_object( 'pressure_psi', nfr.pressure_psi, 'flow_rate_gpm', nfr.flow_rate_gpm ) ORDER BY nfr.pressure_psi ) FILTER (WHERE nfr.id IS NOT NULL) as flow_rates FROM nozzle_types nt LEFT JOIN nozzle_flow_rates nfr ON nt.id = nfr.nozzle_type_id WHERE 1=1 `; const queryParams = []; if (manufacturer) { queryParams.push(manufacturer); query += ` AND nt.manufacturer ILIKE $${queryParams.length}`; } if (droplet_size) { queryParams.push(droplet_size); query += ` AND nt.droplet_size = $${queryParams.length}`; } if (spray_pattern) { queryParams.push(spray_pattern); query += ` AND nt.spray_pattern = $${queryParams.length}`; } if (orifice_size) { queryParams.push(orifice_size); query += ` AND nt.orifice_size = $${queryParams.length}`; } query += ' GROUP BY nt.id ORDER BY nt.manufacturer, nt.name'; const result = await pool.query(query, queryParams); // Group by manufacturer and droplet size for easier frontend handling const nozzlesByManufacturer = result.rows.reduce((acc, nozzle) => { const manufacturer = nozzle.manufacturer || 'Unknown'; if (!acc[manufacturer]) { acc[manufacturer] = {}; } if (!acc[manufacturer][nozzle.droplet_size]) { acc[manufacturer][nozzle.droplet_size] = []; } acc[manufacturer][nozzle.droplet_size].push({ id: nozzle.id, name: nozzle.name, manufacturer: nozzle.manufacturer, model: nozzle.model, orificeSize: nozzle.orifice_size, sprayAngle: nozzle.spray_angle, dropletSize: nozzle.droplet_size, sprayPattern: nozzle.spray_pattern, material: nozzle.material, threadSize: nozzle.thread_size, colorCode: nozzle.color_code, flowRates: nozzle.flow_rates || [], createdAt: nozzle.created_at }); return acc; }, {}); res.json({ success: true, data: { nozzleTypes: result.rows.map(nt => ({ id: nt.id, name: nt.name, manufacturer: nt.manufacturer, model: nt.model, orificeSize: nt.orifice_size, sprayAngle: nt.spray_angle, dropletSize: nt.droplet_size, sprayPattern: nt.spray_pattern, material: nt.material, threadSize: nt.thread_size, colorCode: nt.color_code, flowRates: nt.flow_rates || [], createdAt: nt.created_at })), nozzlesByManufacturer } }); } catch (error) { next(error); } }); // @route GET /api/nozzles/flow-rate/:nozzleTypeId/:pressurePsi // @desc Get flow rate for specific nozzle at specific pressure // @access Private router.get('/flow-rate/:nozzleTypeId/:pressurePsi', validateParams(idParamSchema), async (req, res, next) => { try { const { nozzleTypeId, pressurePsi } = req.params; // Get exact match or interpolate between pressures const result = await pool.query( `SELECT nfr.pressure_psi, nfr.flow_rate_gpm, nt.name, nt.manufacturer FROM nozzle_flow_rates nfr JOIN nozzle_types nt ON nfr.nozzle_type_id = nt.id WHERE nfr.nozzle_type_id = $1 ORDER BY ABS(nfr.pressure_psi - $2) LIMIT 2`, [nozzleTypeId, pressurePsi] ); if (result.rows.length === 0) { throw new AppError('Flow rate data not available for this nozzle', 404); } let flowRate; if (result.rows.length === 1 || result.rows[0].pressure_psi === parseInt(pressurePsi)) { // Exact match or only one data point flowRate = parseFloat(result.rows[0].flow_rate_gpm); } else { // Interpolate between two points const p1 = result.rows[0]; const p2 = result.rows[1]; const pressure = parseInt(pressurePsi); if (p1.pressure_psi === p2.pressure_psi) { flowRate = parseFloat(p1.flow_rate_gpm); } else { // Linear interpolation const slope = (p2.flow_rate_gpm - p1.flow_rate_gpm) / (p2.pressure_psi - p1.pressure_psi); flowRate = parseFloat(p1.flow_rate_gpm) + slope * (pressure - p1.pressure_psi); flowRate = Math.round(flowRate * 1000) / 1000; // Round to 3 decimal places } } res.json({ success: true, data: { nozzleTypeId: parseInt(nozzleTypeId), pressurePsi: parseInt(pressurePsi), flowRateGpm: flowRate, nozzleName: result.rows[0].name, manufacturer: result.rows[0].manufacturer } }); } catch (error) { next(error); } }); // @route GET /api/nozzles // @desc Get all user's nozzles // @access Private router.get('/', async (req, res, next) => { try { const result = await pool.query( `SELECT un.*, nt.name as type_name, nt.manufacturer as type_manufacturer, nt.model as type_model, nt.orifice_size, nt.spray_angle, nt.flow_rate_gpm, nt.droplet_size, nt.spray_pattern, nt.pressure_range_psi FROM user_nozzles un LEFT JOIN nozzle_types nt ON un.nozzle_type_id = nt.id WHERE un.user_id = $1 ORDER BY nt.manufacturer, nt.name, un.custom_name`, [req.user.id] ); res.json({ success: true, data: { nozzles: result.rows.map(item => ({ id: item.id, nozzleTypeId: item.nozzle_type_id, typeName: item.type_name, typeManufacturer: item.type_manufacturer, typeModel: item.type_model, orificeSize: item.orifice_size, sprayAngle: item.spray_angle, flowRateGpm: parseFloat(item.flow_rate_gpm), dropletSize: item.droplet_size, sprayPattern: item.spray_pattern, pressureRangePsi: item.pressure_range_psi, customName: item.custom_name, quantity: item.quantity, condition: item.condition, purchaseDate: item.purchase_date, notes: item.notes, createdAt: item.created_at, updatedAt: item.updated_at })) } }); } catch (error) { next(error); } }); // @route GET /api/nozzles/:id // @desc Get single user nozzle // @access Private router.get('/:id', validateParams(idParamSchema), async (req, res, next) => { try { const nozzleId = req.params.id; const result = await pool.query( `SELECT un.*, nt.name as type_name, nt.manufacturer as type_manufacturer, nt.model as type_model, nt.orifice_size, nt.spray_angle, nt.flow_rate_gpm, nt.droplet_size, nt.spray_pattern, nt.pressure_range_psi FROM user_nozzles un LEFT JOIN nozzle_types nt ON un.nozzle_type_id = nt.id WHERE un.id = $1 AND un.user_id = $2`, [nozzleId, req.user.id] ); if (result.rows.length === 0) { throw new AppError('Nozzle not found', 404); } const item = result.rows[0]; res.json({ success: true, data: { nozzle: { id: item.id, nozzleTypeId: item.nozzle_type_id, typeName: item.type_name, typeManufacturer: item.type_manufacturer, typeModel: item.type_model, orificeSize: item.orifice_size, sprayAngle: item.spray_angle, flowRateGpm: parseFloat(item.flow_rate_gpm), dropletSize: item.droplet_size, sprayPattern: item.spray_pattern, pressureRangePsi: item.pressure_range_psi, customName: item.custom_name, quantity: item.quantity, condition: item.condition, purchaseDate: item.purchase_date, notes: item.notes, createdAt: item.created_at, updatedAt: item.updated_at } } }); } catch (error) { next(error); } }); // @route POST /api/nozzles // @desc Add new nozzle to user's inventory // @access Private router.post('/', async (req, res, next) => { try { const { nozzleTypeId, customName, quantity, condition, purchaseDate, notes } = req.body; // Validate nozzle type exists if provided if (nozzleTypeId) { const typeCheck = await pool.query( 'SELECT id, name, manufacturer FROM nozzle_types WHERE id = $1', [nozzleTypeId] ); if (typeCheck.rows.length === 0) { throw new AppError('Nozzle type not found', 404); } } // Either nozzleTypeId or customName is required if (!nozzleTypeId && !customName) { throw new AppError('Either nozzle type or custom name is required', 400); } const result = await pool.query( `INSERT INTO user_nozzles (user_id, nozzle_type_id, custom_name, quantity, condition, purchase_date, notes) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [req.user.id, nozzleTypeId, customName, quantity || 1, condition || 'good', purchaseDate, notes] ); const nozzle = result.rows[0]; res.status(201).json({ success: true, message: 'Nozzle added successfully', data: { nozzle: { id: nozzle.id, nozzleTypeId: nozzle.nozzle_type_id, customName: nozzle.custom_name, quantity: nozzle.quantity, condition: nozzle.condition, purchaseDate: nozzle.purchase_date, notes: nozzle.notes, createdAt: nozzle.created_at, updatedAt: nozzle.updated_at } } }); } catch (error) { next(error); } }); // @route PUT /api/nozzles/:id // @desc Update user nozzle // @access Private router.put('/:id', validateParams(idParamSchema), async (req, res, next) => { try { const nozzleId = req.params.id; const { nozzleTypeId, customName, quantity, condition, purchaseDate, notes } = req.body; // Check if nozzle exists and belongs to user const checkResult = await pool.query( 'SELECT id FROM user_nozzles WHERE id = $1 AND user_id = $2', [nozzleId, req.user.id] ); if (checkResult.rows.length === 0) { throw new AppError('Nozzle not found', 404); } const result = await pool.query( `UPDATE user_nozzles SET nozzle_type_id = $1, custom_name = $2, quantity = $3, condition = $4, purchase_date = $5, notes = $6, updated_at = CURRENT_TIMESTAMP WHERE id = $7 RETURNING *`, [nozzleTypeId, customName, quantity, condition, purchaseDate, notes, nozzleId] ); const nozzle = result.rows[0]; res.json({ success: true, message: 'Nozzle updated successfully', data: { nozzle: { id: nozzle.id, nozzleTypeId: nozzle.nozzle_type_id, customName: nozzle.custom_name, quantity: nozzle.quantity, condition: nozzle.condition, purchaseDate: nozzle.purchase_date, notes: nozzle.notes, createdAt: nozzle.created_at, updatedAt: nozzle.updated_at } } }); } catch (error) { next(error); } }); // @route DELETE /api/nozzles/:id // @desc Delete user nozzle // @access Private router.delete('/:id', validateParams(idParamSchema), async (req, res, next) => { try { const nozzleId = req.params.id; // Check if nozzle exists and belongs to user const checkResult = await pool.query( 'SELECT id FROM user_nozzles WHERE id = $1 AND user_id = $2', [nozzleId, req.user.id] ); if (checkResult.rows.length === 0) { throw new AppError('Nozzle not found', 404); } // Check if nozzle is assigned to any equipment const assignmentCheck = await pool.query( 'SELECT COUNT(*) as count FROM equipment_nozzle_assignments WHERE user_nozzle_id = $1', [nozzleId] ); if (parseInt(assignmentCheck.rows[0].count) > 0) { throw new AppError('Cannot delete nozzle that is assigned to equipment', 400); } await pool.query('DELETE FROM user_nozzles WHERE id = $1', [nozzleId]); res.json({ success: true, message: 'Nozzle deleted successfully' }); } catch (error) { next(error); } }); // @route GET /api/nozzles/equipment/:equipmentId/assignments // @desc Get nozzle assignments for specific equipment // @access Private router.get('/equipment/:equipmentId/assignments', validateParams(idParamSchema), async (req, res, next) => { try { const equipmentId = req.params.equipmentId; // Verify equipment belongs to user const equipmentCheck = await pool.query( 'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2', [equipmentId, req.user.id] ); if (equipmentCheck.rows.length === 0) { throw new AppError('Equipment not found', 404); } const result = await pool.query( `SELECT ena.*, un.custom_name as nozzle_custom_name, un.quantity as nozzle_total_quantity, nt.name as nozzle_type_name, nt.manufacturer, nt.model, nt.orifice_size, nt.spray_angle, nt.flow_rate_gpm, nt.droplet_size, nt.spray_pattern FROM equipment_nozzle_assignments ena JOIN user_nozzles un ON ena.user_nozzle_id = un.id LEFT JOIN nozzle_types nt ON un.nozzle_type_id = nt.id WHERE ena.user_equipment_id = $1 ORDER BY ena.position, nt.manufacturer, nt.name`, [equipmentId] ); res.json({ success: true, data: { assignments: result.rows.map(item => ({ id: item.id, userEquipmentId: item.user_equipment_id, userNozzleId: item.user_nozzle_id, position: item.position, quantityAssigned: item.quantity_assigned, assignedDate: item.assigned_date, nozzleCustomName: item.nozzle_custom_name, nozzleTotalQuantity: item.nozzle_total_quantity, nozzleTypeName: item.nozzle_type_name, manufacturer: item.manufacturer, model: item.model, orificeSize: item.orifice_size, sprayAngle: item.spray_angle, flowRateGpm: parseFloat(item.flow_rate_gpm), dropletSize: item.droplet_size, sprayPattern: item.spray_pattern, createdAt: item.created_at })) } }); } catch (error) { next(error); } }); // @route POST /api/nozzles/equipment/:equipmentId/assignments // @desc Assign nozzle to equipment // @access Private router.post('/equipment/:equipmentId/assignments', validateParams(idParamSchema), async (req, res, next) => { try { const equipmentId = req.params.equipmentId; const { userNozzleId, position, quantityAssigned } = req.body; // Verify equipment belongs to user const equipmentCheck = await pool.query( 'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2', [equipmentId, req.user.id] ); if (equipmentCheck.rows.length === 0) { throw new AppError('Equipment not found', 404); } // Verify nozzle belongs to user const nozzleCheck = await pool.query( 'SELECT id, quantity FROM user_nozzles WHERE id = $1 AND user_id = $2', [userNozzleId, req.user.id] ); if (nozzleCheck.rows.length === 0) { throw new AppError('Nozzle not found', 404); } const nozzle = nozzleCheck.rows[0]; // Check if enough nozzles are available const assignedCount = await pool.query( 'SELECT COALESCE(SUM(quantity_assigned), 0) as total_assigned FROM equipment_nozzle_assignments WHERE user_nozzle_id = $1', [userNozzleId] ); const totalAssigned = parseInt(assignedCount.rows[0].total_assigned); const requestedQuantity = quantityAssigned || 1; if (totalAssigned + requestedQuantity > nozzle.quantity) { throw new AppError(`Not enough nozzles available. Available: ${nozzle.quantity - totalAssigned}, Requested: ${requestedQuantity}`, 400); } const result = await pool.query( `INSERT INTO equipment_nozzle_assignments (user_equipment_id, user_nozzle_id, position, quantity_assigned) VALUES ($1, $2, $3, $4) RETURNING *`, [equipmentId, userNozzleId, position || 'center', requestedQuantity] ); const assignment = result.rows[0]; res.status(201).json({ success: true, message: 'Nozzle assigned to equipment successfully', data: { assignment: { id: assignment.id, userEquipmentId: assignment.user_equipment_id, userNozzleId: assignment.user_nozzle_id, position: assignment.position, quantityAssigned: assignment.quantity_assigned, assignedDate: assignment.assigned_date, createdAt: assignment.created_at } } }); } catch (error) { next(error); } }); // @route DELETE /api/nozzles/assignments/:assignmentId // @desc Remove nozzle assignment from equipment // @access Private router.delete('/assignments/:assignmentId', validateParams(idParamSchema), async (req, res, next) => { try { const assignmentId = req.params.assignmentId; // Verify assignment belongs to user's equipment const checkResult = await pool.query( `SELECT ena.id FROM equipment_nozzle_assignments ena JOIN user_equipment ue ON ena.user_equipment_id = ue.id WHERE ena.id = $1 AND ue.user_id = $2`, [assignmentId, req.user.id] ); if (checkResult.rows.length === 0) { throw new AppError('Assignment not found', 404); } await pool.query('DELETE FROM equipment_nozzle_assignments WHERE id = $1', [assignmentId]); res.json({ success: true, message: 'Nozzle assignment removed successfully' }); } catch (error) { next(error); } }); // PUMP ASSIGNMENT ROUTES // @route GET /api/nozzles/equipment/:equipmentId/pump // @desc Get pump assigned to sprayer // @access Private router.get('/equipment/:equipmentId/pump', validateParams(idParamSchema), async (req, res, next) => { try { const equipmentId = req.params.equipmentId; // Verify equipment belongs to user const equipmentCheck = await pool.query( 'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2', [equipmentId, req.user.id] ); if (equipmentCheck.rows.length === 0) { throw new AppError('Equipment not found', 404); } const result = await pool.query( `SELECT epa.*, pump_eq.custom_name as pump_name, pump_eq.manufacturer as pump_manufacturer, pump_eq.model as pump_model, pump_eq.max_gpm, pump_eq.max_psi FROM equipment_pump_assignments epa JOIN user_equipment pump_eq ON epa.pump_equipment_id = pump_eq.id WHERE epa.sprayer_equipment_id = $1 AND epa.is_active = true`, [equipmentId] ); res.json({ success: true, data: { pumpAssignment: result.rows.length > 0 ? { id: result.rows[0].id, sprayerEquipmentId: result.rows[0].sprayer_equipment_id, pumpEquipmentId: result.rows[0].pump_equipment_id, pumpName: result.rows[0].pump_name, pumpManufacturer: result.rows[0].pump_manufacturer, pumpModel: result.rows[0].pump_model, maxGpm: parseFloat(result.rows[0].max_gpm) || null, maxPsi: parseFloat(result.rows[0].max_psi) || null, assignedDate: result.rows[0].assigned_date, notes: result.rows[0].notes, isActive: result.rows[0].is_active } : null } }); } catch (error) { next(error); } }); // @route POST /api/nozzles/equipment/:equipmentId/pump // @desc Assign pump to sprayer // @access Private router.post('/equipment/:equipmentId/pump', validateParams(idParamSchema), async (req, res, next) => { try { const sprayerEquipmentId = req.params.equipmentId; const { pumpEquipmentId, notes } = req.body; // Verify both equipment belong to user const equipmentCheck = await pool.query( 'SELECT id, category_id FROM user_equipment WHERE id IN ($1, $2) AND user_id = $3', [sprayerEquipmentId, pumpEquipmentId, req.user.id] ); if (equipmentCheck.rows.length !== 2) { throw new AppError('Equipment not found', 404); } // Deactivate any existing pump assignments for this sprayer await pool.query( 'UPDATE equipment_pump_assignments SET is_active = false WHERE sprayer_equipment_id = $1', [sprayerEquipmentId] ); // Create new assignment const result = await pool.query( `INSERT INTO equipment_pump_assignments (sprayer_equipment_id, pump_equipment_id, notes) VALUES ($1, $2, $3) RETURNING *`, [sprayerEquipmentId, pumpEquipmentId, notes] ); const assignment = result.rows[0]; res.status(201).json({ success: true, message: 'Pump assigned to sprayer successfully', data: { assignment: { id: assignment.id, sprayerEquipmentId: assignment.sprayer_equipment_id, pumpEquipmentId: assignment.pump_equipment_id, assignedDate: assignment.assigned_date, notes: assignment.notes, isActive: assignment.is_active } } }); } catch (error) { next(error); } }); // @route DELETE /api/nozzles/equipment/:equipmentId/pump // @desc Remove pump assignment from sprayer // @access Private router.delete('/equipment/:equipmentId/pump', validateParams(idParamSchema), async (req, res, next) => { try { const sprayerEquipmentId = req.params.equipmentId; // Verify sprayer belongs to user const equipmentCheck = await pool.query( 'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2', [sprayerEquipmentId, req.user.id] ); if (equipmentCheck.rows.length === 0) { throw new AppError('Equipment not found', 404); } await pool.query( 'UPDATE equipment_pump_assignments SET is_active = false WHERE sprayer_equipment_id = $1', [sprayerEquipmentId] ); res.json({ success: true, message: 'Pump assignment removed successfully' }); } catch (error) { next(error); } }); // NOZZLE CONFIGURATION ROUTES // @route GET /api/nozzles/equipment/:equipmentId/configurations // @desc Get nozzle configurations for equipment // @access Private router.get('/equipment/:equipmentId/configurations', validateParams(idParamSchema), async (req, res, next) => { try { const equipmentId = req.params.equipmentId; // Verify equipment belongs to user const equipmentCheck = await pool.query( 'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2', [equipmentId, req.user.id] ); if (equipmentCheck.rows.length === 0) { throw new AppError('Equipment not found', 404); } const result = await pool.query( `SELECT enc.*, COUNT(cna.id) as nozzle_count FROM equipment_nozzle_configurations enc LEFT JOIN configuration_nozzle_assignments cna ON enc.id = cna.configuration_id WHERE enc.user_equipment_id = $1 GROUP BY enc.id ORDER BY enc.is_default DESC, enc.configuration_name`, [equipmentId] ); res.json({ success: true, data: { configurations: result.rows.map(config => ({ id: config.id, userEquipmentId: config.user_equipment_id, configurationName: config.configuration_name, isDefault: config.is_default, nozzleCount: parseInt(config.nozzle_count), createdAt: config.created_at, updatedAt: config.updated_at })) } }); } catch (error) { next(error); } }); // @route GET /api/nozzles/configurations/:configId/details // @desc Get detailed nozzle configuration with assignments // @access Private router.get('/configurations/:configId/details', validateParams(idParamSchema), async (req, res, next) => { try { const configId = req.params.configId; // Verify configuration belongs to user's equipment const configCheck = await pool.query( `SELECT enc.* FROM equipment_nozzle_configurations enc JOIN user_equipment ue ON enc.user_equipment_id = ue.id WHERE enc.id = $1 AND ue.user_id = $2`, [configId, req.user.id] ); if (configCheck.rows.length === 0) { throw new AppError('Configuration not found', 404); } const result = await pool.query( `SELECT cna.*, nt.name, nt.manufacturer, nt.orifice_size, nt.spray_angle, nt.droplet_size, nt.spray_pattern, nt.color_code, nfr.flow_rate_gpm FROM configuration_nozzle_assignments cna JOIN nozzle_types nt ON cna.nozzle_type_id = nt.id LEFT JOIN nozzle_flow_rates nfr ON nt.id = nfr.nozzle_type_id AND nfr.pressure_psi = cna.operating_pressure_psi WHERE cna.configuration_id = $1 ORDER BY cna.position`, [configId] ); res.json({ success: true, data: { configuration: configCheck.rows[0], assignments: result.rows.map(item => ({ id: item.id, configurationId: item.configuration_id, nozzleTypeId: item.nozzle_type_id, position: item.position, quantity: item.quantity, operatingPressurePsi: item.operating_pressure_psi, notes: item.notes, nozzleName: item.name, manufacturer: item.manufacturer, orificeSize: item.orifice_size, sprayAngle: item.spray_angle, dropletSize: item.droplet_size, sprayPattern: item.spray_pattern, colorCode: item.color_code, flowRateGpm: parseFloat(item.flow_rate_gpm) || null, createdAt: item.created_at })) } }); } catch (error) { next(error); } }); module.exports = router;