equipment stuff

This commit is contained in:
Jake Kasper
2025-08-22 09:17:22 -04:00
parent f95422325c
commit b7ceed70a5
7 changed files with 2137 additions and 260 deletions

View File

@@ -10,6 +10,7 @@ const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const propertyRoutes = require('./routes/properties');
const equipmentRoutes = require('./routes/equipment');
const nozzleRoutes = require('./routes/nozzles');
const productRoutes = require('./routes/products');
const applicationRoutes = require('./routes/applications');
const weatherRoutes = require('./routes/weather');
@@ -82,6 +83,7 @@ app.use('/api/auth', authLimiter, authRoutes);
app.use('/api/users', authenticateToken, userRoutes);
app.use('/api/properties', authenticateToken, propertyRoutes);
app.use('/api/equipment', authenticateToken, equipmentRoutes);
app.use('/api/nozzles', authenticateToken, nozzleRoutes);
app.use('/api/products', authenticateToken, productRoutes);
app.use('/api/applications', authenticateToken, applicationRoutes);
app.use('/api/weather', authenticateToken, weatherRoutes);

View File

@@ -1,28 +1,66 @@
const express = require('express');
const pool = require('../config/database');
const { validateRequest, validateParams } = require('../utils/validation');
const { equipmentSchema, idParamSchema } = require('../utils/validation');
const { idParamSchema } = require('../utils/validation');
const { AppError } = require('../middleware/errorHandler');
const router = express.Router();
// @route GET /api/equipment/categories
// @desc Get all equipment categories
// @access Private
router.get('/categories', async (req, res, next) => {
try {
const result = await pool.query(
'SELECT * FROM equipment_categories ORDER BY name'
);
res.json({
success: true,
data: {
categories: result.rows
}
});
} catch (error) {
next(error);
}
});
// @route GET /api/equipment/types
// @desc Get all equipment types
// @access Private
router.get('/types', async (req, res, next) => {
try {
const result = await pool.query(
'SELECT * FROM equipment_types ORDER BY category, name'
);
const { category_id } = req.query;
let query = `
SELECT et.*, ec.name as category_name, ec.description as category_description
FROM equipment_types et
JOIN equipment_categories ec ON et.category_id = ec.id
`;
const queryParams = [];
if (category_id) {
query += ' WHERE et.category_id = $1';
queryParams.push(category_id);
}
query += ' ORDER BY ec.name, et.name';
const result = await pool.query(query, queryParams);
const equipmentByCategory = result.rows.reduce((acc, equipment) => {
if (!acc[equipment.category]) {
acc[equipment.category] = [];
const categoryName = equipment.category_name;
if (!acc[categoryName]) {
acc[categoryName] = [];
}
acc[equipment.category].push({
acc[categoryName].push({
id: equipment.id,
name: equipment.name,
category: equipment.category,
manufacturer: equipment.manufacturer,
model: equipment.model,
categoryId: equipment.category_id,
categoryName: equipment.category_name,
createdAt: equipment.created_at
});
return acc;
@@ -31,7 +69,16 @@ router.get('/types', async (req, res, next) => {
res.json({
success: true,
data: {
equipmentTypes: result.rows,
equipmentTypes: result.rows.map(et => ({
id: et.id,
name: et.name,
manufacturer: et.manufacturer,
model: et.model,
categoryId: et.category_id,
categoryName: et.category_name,
categoryDescription: et.category_description,
createdAt: et.created_at
})),
equipmentByCategory
}
});
@@ -45,14 +92,31 @@ router.get('/types', async (req, res, next) => {
// @access Private
router.get('/', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT ue.*, et.name as type_name, et.category
FROM user_equipment ue
JOIN equipment_types et ON ue.equipment_type_id = et.id
WHERE ue.user_id = $1
ORDER BY et.category, et.name`,
[req.user.id]
);
const { category_id, is_active } = req.query;
let query = `
SELECT ue.*, et.name as type_name, et.manufacturer as type_manufacturer, et.model as type_model,
ec.name as category_name
FROM user_equipment ue
LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id
LEFT JOIN equipment_categories ec ON ue.category_id = ec.id
WHERE ue.user_id = $1
`;
const queryParams = [req.user.id];
if (category_id) {
queryParams.push(category_id);
query += ` AND ue.category_id = $${queryParams.length}`;
}
if (is_active !== undefined) {
queryParams.push(is_active === 'true');
query += ` AND ue.is_active = $${queryParams.length}`;
}
query += ' ORDER BY ec.name, COALESCE(ue.custom_name, et.name)';
const result = await pool.query(query, queryParams);
res.json({
success: true,
@@ -60,14 +124,43 @@ router.get('/', async (req, res, next) => {
equipment: result.rows.map(item => ({
id: item.id,
equipmentTypeId: item.equipment_type_id,
categoryId: item.category_id,
typeName: item.type_name,
category: item.category,
typeManufacturer: item.type_manufacturer,
typeModel: item.type_model,
categoryName: item.category_name,
customName: item.custom_name,
tankSize: parseFloat(item.tank_size),
pumpGpm: parseFloat(item.pump_gpm),
nozzleGpm: parseFloat(item.nozzle_gpm),
nozzleCount: item.nozzle_count,
spreaderWidth: parseFloat(item.spreader_width),
manufacturer: item.manufacturer,
model: item.model,
// Spreader fields
capacityLbs: parseFloat(item.capacity_lbs) || null,
spreaderType: item.spreader_type,
spreadWidth: parseFloat(item.spread_width) || null,
// Sprayer fields
tankSizeGallons: parseFloat(item.tank_size_gallons) || null,
sprayerType: item.sprayer_type,
sprayWidthFeet: parseFloat(item.spray_width_feet) || null,
pumpGpm: parseFloat(item.pump_gpm) || null,
pumpPsi: parseFloat(item.pump_psi) || null,
boomSections: item.boom_sections,
// Mower fields
mowerStyle: item.mower_style,
cuttingWidthInches: parseFloat(item.cutting_width_inches) || null,
engineHp: parseFloat(item.engine_hp) || null,
fuelType: item.fuel_type,
// Tool fields
toolType: item.tool_type,
workingWidthInches: parseFloat(item.working_width_inches) || null,
// Pump fields
pumpType: item.pump_type,
maxGpm: parseFloat(item.max_gpm) || null,
maxPsi: parseFloat(item.max_psi) || null,
powerSource: item.power_source,
// General fields
purchaseDate: item.purchase_date,
purchasePrice: parseFloat(item.purchase_price) || null,
notes: item.notes,
isActive: item.is_active,
createdAt: item.created_at,
updatedAt: item.updated_at
}))
@@ -86,9 +179,11 @@ router.get('/:id', validateParams(idParamSchema), async (req, res, next) => {
const equipmentId = req.params.id;
const result = await pool.query(
`SELECT ue.*, et.name as type_name, et.category
`SELECT ue.*, et.name as type_name, et.manufacturer as type_manufacturer, et.model as type_model,
ec.name as category_name
FROM user_equipment ue
JOIN equipment_types et ON ue.equipment_type_id = et.id
LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id
LEFT JOIN equipment_categories ec ON ue.category_id = ec.id
WHERE ue.id = $1 AND ue.user_id = $2`,
[equipmentId, req.user.id]
);
@@ -105,14 +200,43 @@ router.get('/:id', validateParams(idParamSchema), async (req, res, next) => {
equipment: {
id: item.id,
equipmentTypeId: item.equipment_type_id,
categoryId: item.category_id,
typeName: item.type_name,
category: item.category,
typeManufacturer: item.type_manufacturer,
typeModel: item.type_model,
categoryName: item.category_name,
customName: item.custom_name,
tankSize: parseFloat(item.tank_size),
pumpGpm: parseFloat(item.pump_gpm),
nozzleGpm: parseFloat(item.nozzle_gpm),
nozzleCount: item.nozzle_count,
spreaderWidth: parseFloat(item.spreader_width),
manufacturer: item.manufacturer,
model: item.model,
// Spreader fields
capacityLbs: parseFloat(item.capacity_lbs) || null,
spreaderType: item.spreader_type,
spreadWidth: parseFloat(item.spread_width) || null,
// Sprayer fields
tankSizeGallons: parseFloat(item.tank_size_gallons) || null,
sprayerType: item.sprayer_type,
sprayWidthFeet: parseFloat(item.spray_width_feet) || null,
pumpGpm: parseFloat(item.pump_gpm) || null,
pumpPsi: parseFloat(item.pump_psi) || null,
boomSections: item.boom_sections,
// Mower fields
mowerStyle: item.mower_style,
cuttingWidthInches: parseFloat(item.cutting_width_inches) || null,
engineHp: parseFloat(item.engine_hp) || null,
fuelType: item.fuel_type,
// Tool fields
toolType: item.tool_type,
workingWidthInches: parseFloat(item.working_width_inches) || null,
// Pump fields
pumpType: item.pump_type,
maxGpm: parseFloat(item.max_gpm) || null,
maxPsi: parseFloat(item.max_psi) || null,
powerSource: item.power_source,
// General fields
purchaseDate: item.purchase_date,
purchasePrice: parseFloat(item.purchase_price) || null,
notes: item.notes,
isActive: item.is_active,
createdAt: item.created_at,
updatedAt: item.updated_at
}
@@ -126,47 +250,99 @@ router.get('/:id', validateParams(idParamSchema), async (req, res, next) => {
// @route POST /api/equipment
// @desc Add new equipment
// @access Private
router.post('/', validateRequest(equipmentSchema), async (req, res, next) => {
router.post('/', async (req, res, next) => {
try {
const {
equipmentTypeId,
customName,
tankSize,
pumpGpm,
nozzleGpm,
nozzleCount,
spreaderWidth
const {
equipmentTypeId,
categoryId,
customName,
manufacturer,
model,
// Spreader specific fields
capacityLbs,
spreaderType,
spreadWidth,
// Sprayer specific fields
tankSizeGallons,
sprayerType,
sprayWidthFeet,
pumpGpm,
pumpPsi,
boomSections,
// Mower specific fields
mowerStyle,
cuttingWidthInches,
engineHp,
fuelType,
// Tool specific fields
toolType,
workingWidthInches,
// Pump specific fields
pumpType,
maxGpm,
maxPsi,
powerSource,
// General fields
purchaseDate,
purchasePrice,
notes
} = req.body;
// Verify equipment type exists
const typeCheck = await pool.query(
'SELECT id, name, category FROM equipment_types WHERE id = $1',
[equipmentTypeId]
);
if (typeCheck.rows.length === 0) {
throw new AppError('Equipment type not found', 404);
// Validate required fields
if (!categoryId && !equipmentTypeId) {
throw new AppError('Either category or equipment type is required', 400);
}
const equipmentType = typeCheck.rows[0];
// If equipmentTypeId is provided, verify it exists and get category
let finalCategoryId = categoryId;
let equipmentType = null;
if (equipmentTypeId) {
const typeCheck = await pool.query(
'SELECT id, name, category_id, manufacturer, model FROM equipment_types WHERE id = $1',
[equipmentTypeId]
);
// Validate required fields based on equipment type
if (equipmentType.category === 'sprayer') {
if (!tankSize || !pumpGpm || !nozzleGpm || !nozzleCount) {
throw new AppError('Tank size, pump GPM, nozzle GPM, and nozzle count are required for sprayers', 400);
if (typeCheck.rows.length === 0) {
throw new AppError('Equipment type not found', 404);
}
equipmentType = typeCheck.rows[0];
finalCategoryId = equipmentType.category_id;
}
if (equipmentType.category === 'spreader' && !spreaderWidth) {
throw new AppError('Spreader width is required for spreaders', 400);
// Verify category exists
if (finalCategoryId) {
const categoryCheck = await pool.query(
'SELECT id, name FROM equipment_categories WHERE id = $1',
[finalCategoryId]
);
if (categoryCheck.rows.length === 0) {
throw new AppError('Category not found', 404);
}
}
const result = await pool.query(
`INSERT INTO user_equipment
(user_id, equipment_type_id, custom_name, tank_size, pump_gpm, nozzle_gpm, nozzle_count, spreader_width)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
(user_id, equipment_type_id, category_id, custom_name, manufacturer, model,
capacity_lbs, spreader_type, spread_width,
tank_size_gallons, sprayer_type, spray_width_feet, pump_gpm, pump_psi, boom_sections,
mower_style, cutting_width_inches, engine_hp, fuel_type,
tool_type, working_width_inches,
pump_type, max_gpm, max_psi, power_source,
purchase_date, purchase_price, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)
RETURNING *`,
[req.user.id, equipmentTypeId, customName, tankSize, pumpGpm, nozzleGpm, nozzleCount, spreaderWidth]
[
req.user.id, equipmentTypeId, finalCategoryId, customName, manufacturer, model,
capacityLbs, spreaderType, spreadWidth,
tankSizeGallons, sprayerType, sprayWidthFeet, pumpGpm, pumpPsi, boomSections,
mowerStyle, cuttingWidthInches, engineHp, fuelType,
toolType, workingWidthInches,
pumpType, maxGpm, maxPsi, powerSource,
purchaseDate, purchasePrice, notes
]
);
const equipment = result.rows[0];
@@ -178,14 +354,33 @@ router.post('/', validateRequest(equipmentSchema), async (req, res, next) => {
equipment: {
id: equipment.id,
equipmentTypeId: equipment.equipment_type_id,
typeName: equipmentType.name,
category: equipmentType.category,
categoryId: equipment.category_id,
customName: equipment.custom_name,
tankSize: parseFloat(equipment.tank_size),
pumpGpm: parseFloat(equipment.pump_gpm),
nozzleGpm: parseFloat(equipment.nozzle_gpm),
nozzleCount: equipment.nozzle_count,
spreaderWidth: parseFloat(equipment.spreader_width),
manufacturer: equipment.manufacturer,
model: equipment.model,
capacityLbs: parseFloat(equipment.capacity_lbs) || null,
spreaderType: equipment.spreader_type,
spreadWidth: parseFloat(equipment.spread_width) || null,
tankSizeGallons: parseFloat(equipment.tank_size_gallons) || null,
sprayerType: equipment.sprayer_type,
sprayWidthFeet: parseFloat(equipment.spray_width_feet) || null,
pumpGpm: parseFloat(equipment.pump_gpm) || null,
pumpPsi: parseFloat(equipment.pump_psi) || null,
boomSections: equipment.boom_sections,
mowerStyle: equipment.mower_style,
cuttingWidthInches: parseFloat(equipment.cutting_width_inches) || null,
engineHp: parseFloat(equipment.engine_hp) || null,
fuelType: equipment.fuel_type,
toolType: equipment.tool_type,
workingWidthInches: parseFloat(equipment.working_width_inches) || null,
pumpType: equipment.pump_type,
maxGpm: parseFloat(equipment.max_gpm) || null,
maxPsi: parseFloat(equipment.max_psi) || null,
powerSource: equipment.power_source,
purchaseDate: equipment.purchase_date,
purchasePrice: parseFloat(equipment.purchase_price) || null,
notes: equipment.notes,
isActive: equipment.is_active,
createdAt: equipment.created_at,
updatedAt: equipment.updated_at
}
@@ -199,17 +394,38 @@ router.post('/', validateRequest(equipmentSchema), async (req, res, next) => {
// @route PUT /api/equipment/:id
// @desc Update equipment
// @access Private
router.put('/:id', validateParams(idParamSchema), validateRequest(equipmentSchema), async (req, res, next) => {
router.put('/:id', validateParams(idParamSchema), async (req, res, next) => {
try {
const equipmentId = req.params.id;
const {
equipmentTypeId,
customName,
tankSize,
pumpGpm,
nozzleGpm,
nozzleCount,
spreaderWidth
const {
equipmentTypeId,
categoryId,
customName,
manufacturer,
model,
capacityLbs,
spreaderType,
spreadWidth,
tankSizeGallons,
sprayerType,
sprayWidthFeet,
pumpGpm,
pumpPsi,
boomSections,
mowerStyle,
cuttingWidthInches,
engineHp,
fuelType,
toolType,
workingWidthInches,
pumpType,
maxGpm,
maxPsi,
powerSource,
purchaseDate,
purchasePrice,
notes,
isActive
} = req.body;
// Check if equipment exists and belongs to user
@@ -222,36 +438,28 @@ router.put('/:id', validateParams(idParamSchema), validateRequest(equipmentSchem
throw new AppError('Equipment not found', 404);
}
// Verify equipment type exists
const typeCheck = await pool.query(
'SELECT id, name, category FROM equipment_types WHERE id = $1',
[equipmentTypeId]
);
if (typeCheck.rows.length === 0) {
throw new AppError('Equipment type not found', 404);
}
const equipmentType = typeCheck.rows[0];
// Validate required fields based on equipment type
if (equipmentType.category === 'sprayer') {
if (!tankSize || !pumpGpm || !nozzleGpm || !nozzleCount) {
throw new AppError('Tank size, pump GPM, nozzle GPM, and nozzle count are required for sprayers', 400);
}
}
if (equipmentType.category === 'spreader' && !spreaderWidth) {
throw new AppError('Spreader width is required for spreaders', 400);
}
const result = await pool.query(
`UPDATE user_equipment
SET equipment_type_id = $1, custom_name = $2, tank_size = $3, pump_gpm = $4,
nozzle_gpm = $5, nozzle_count = $6, spreader_width = $7, updated_at = CURRENT_TIMESTAMP
WHERE id = $8
SET equipment_type_id = $1, category_id = $2, custom_name = $3, manufacturer = $4, model = $5,
capacity_lbs = $6, spreader_type = $7, spread_width = $8,
tank_size_gallons = $9, sprayer_type = $10, spray_width_feet = $11, pump_gpm = $12, pump_psi = $13, boom_sections = $14,
mower_style = $15, cutting_width_inches = $16, engine_hp = $17, fuel_type = $18,
tool_type = $19, working_width_inches = $20,
pump_type = $21, max_gpm = $22, max_psi = $23, power_source = $24,
purchase_date = $25, purchase_price = $26, notes = $27, is_active = $28,
updated_at = CURRENT_TIMESTAMP
WHERE id = $29
RETURNING *`,
[equipmentTypeId, customName, tankSize, pumpGpm, nozzleGpm, nozzleCount, spreaderWidth, equipmentId]
[
equipmentTypeId, categoryId, customName, manufacturer, model,
capacityLbs, spreaderType, spreadWidth,
tankSizeGallons, sprayerType, sprayWidthFeet, pumpGpm, pumpPsi, boomSections,
mowerStyle, cuttingWidthInches, engineHp, fuelType,
toolType, workingWidthInches,
pumpType, maxGpm, maxPsi, powerSource,
purchaseDate, purchasePrice, notes, isActive !== undefined ? isActive : true,
equipmentId
]
);
const equipment = result.rows[0];
@@ -263,14 +471,33 @@ router.put('/:id', validateParams(idParamSchema), validateRequest(equipmentSchem
equipment: {
id: equipment.id,
equipmentTypeId: equipment.equipment_type_id,
typeName: equipmentType.name,
category: equipmentType.category,
categoryId: equipment.category_id,
customName: equipment.custom_name,
tankSize: parseFloat(equipment.tank_size),
pumpGpm: parseFloat(equipment.pump_gpm),
nozzleGpm: parseFloat(equipment.nozzle_gpm),
nozzleCount: equipment.nozzle_count,
spreaderWidth: parseFloat(equipment.spreader_width),
manufacturer: equipment.manufacturer,
model: equipment.model,
capacityLbs: parseFloat(equipment.capacity_lbs) || null,
spreaderType: equipment.spreader_type,
spreadWidth: parseFloat(equipment.spread_width) || null,
tankSizeGallons: parseFloat(equipment.tank_size_gallons) || null,
sprayerType: equipment.sprayer_type,
sprayWidthFeet: parseFloat(equipment.spray_width_feet) || null,
pumpGpm: parseFloat(equipment.pump_gpm) || null,
pumpPsi: parseFloat(equipment.pump_psi) || null,
boomSections: equipment.boom_sections,
mowerStyle: equipment.mower_style,
cuttingWidthInches: parseFloat(equipment.cutting_width_inches) || null,
engineHp: parseFloat(equipment.engine_hp) || null,
fuelType: equipment.fuel_type,
toolType: equipment.tool_type,
workingWidthInches: parseFloat(equipment.working_width_inches) || null,
pumpType: equipment.pump_type,
maxGpm: parseFloat(equipment.max_gpm) || null,
maxPsi: parseFloat(equipment.max_psi) || null,
powerSource: equipment.power_source,
purchaseDate: equipment.purchase_date,
purchasePrice: parseFloat(equipment.purchase_price) || null,
notes: equipment.notes,
isActive: equipment.is_active,
createdAt: equipment.created_at,
updatedAt: equipment.updated_at
}
@@ -321,128 +548,4 @@ router.delete('/:id', validateParams(idParamSchema), async (req, res, next) => {
}
});
// @route GET /api/equipment/:id/calculations
// @desc Get application calculations for equipment
// @access Private
router.get('/:id/calculations', validateParams(idParamSchema), async (req, res, next) => {
try {
const equipmentId = req.params.id;
const { area, rateAmount, rateUnit } = req.query;
if (!area || !rateAmount || !rateUnit) {
throw new AppError('Area, rate amount, and rate unit are required for calculations', 400);
}
// Get equipment details
const equipmentResult = await pool.query(
`SELECT ue.*, et.category
FROM user_equipment ue
JOIN equipment_types et ON ue.equipment_type_id = et.id
WHERE ue.id = $1 AND ue.user_id = $2`,
[equipmentId, req.user.id]
);
if (equipmentResult.rows.length === 0) {
throw new AppError('Equipment not found', 404);
}
const equipment = equipmentResult.rows[0];
const targetArea = parseFloat(area);
const rate = parseFloat(rateAmount);
let calculations = {};
if (equipment.category === 'sprayer') {
// Liquid application calculations
const tankSize = parseFloat(equipment.tank_size);
const pumpGpm = parseFloat(equipment.pump_gpm);
const nozzleGpm = parseFloat(equipment.nozzle_gpm);
const nozzleCount = parseInt(equipment.nozzle_count);
// Calculate total nozzle output
const totalNozzleGpm = nozzleGpm * nozzleCount;
let productAmount, waterAmount, targetSpeed;
if (rateUnit.includes('gal/1000sqft') || rateUnit.includes('gal/acre')) {
// Gallons per area - calculate water volume needed
const multiplier = rateUnit.includes('acre') ? targetArea / 43560 : targetArea / 1000;
waterAmount = rate * multiplier;
productAmount = 0; // Pure water application
} else if (rateUnit.includes('oz/gal/1000sqft')) {
// Ounces per gallon per 1000 sqft
const waterGallonsNeeded = targetArea / 1000; // 1 gallon per 1000 sqft default
productAmount = rate * waterGallonsNeeded;
waterAmount = waterGallonsNeeded * 128; // Convert to ounces
} else {
// Default liquid calculation
productAmount = rate * (targetArea / 1000);
waterAmount = tankSize * 128; // Tank capacity in ounces
}
// Calculate target speed (assuming 20 foot spray width as default)
const sprayWidth = 20; // feet
const minutesToCover = waterAmount / (totalNozzleGpm * 128); // Convert GPM to oz/min
const distanceFeet = targetArea / sprayWidth;
targetSpeed = (distanceFeet / minutesToCover) * (60 / 5280); // Convert to MPH
calculations = {
productAmount: Math.round(productAmount * 100) / 100,
waterAmount: Math.round(waterAmount * 100) / 100,
targetSpeed: Math.round(targetSpeed * 100) / 100,
tankCount: Math.ceil(waterAmount / (tankSize * 128)),
applicationType: 'liquid',
unit: rateUnit.includes('oz') ? 'oz' : 'gal'
};
} else if (equipment.category === 'spreader') {
// Granular application calculations
const spreaderWidth = parseFloat(equipment.spreader_width);
let productAmount, targetSpeed;
if (rateUnit.includes('lbs/1000sqft')) {
productAmount = rate * (targetArea / 1000);
} else if (rateUnit.includes('lbs/acre')) {
productAmount = rate * (targetArea / 43560);
} else {
productAmount = rate * (targetArea / 1000); // Default to per 1000 sqft
}
// Calculate target speed (assuming 3 MPH walking speed as baseline)
const baselineSpeed = 3; // MPH
const minutesToSpread = 60; // Assume 1 hour coverage
const distanceFeet = targetArea / spreaderWidth;
targetSpeed = (distanceFeet / (minutesToSpread * 60)) * (60 / 5280); // Convert to MPH
calculations = {
productAmount: Math.round(productAmount * 100) / 100,
targetSpeed: Math.round(targetSpeed * 100) / 100,
applicationType: 'granular',
unit: 'lbs',
coverageTime: Math.round((targetArea / (spreaderWidth * baselineSpeed * 5280 / 60)) * 100) / 100
};
}
res.json({
success: true,
data: {
calculations,
equipment: {
id: equipment.id,
category: equipment.category,
tankSize: equipment.tank_size,
spreaderWidth: equipment.spreader_width
},
inputs: {
area: targetArea,
rate: rate,
unit: rateUnit
}
}
});
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -0,0 +1,503 @@
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
// @access Private
router.get('/types', async (req, res, next) => {
try {
const { manufacturer, droplet_size, spray_pattern } = req.query;
let query = 'SELECT * FROM nozzle_types WHERE 1=1';
const queryParams = [];
if (manufacturer) {
queryParams.push(manufacturer);
query += ` AND manufacturer ILIKE $${queryParams.length}`;
}
if (droplet_size) {
queryParams.push(droplet_size);
query += ` AND droplet_size = $${queryParams.length}`;
}
if (spray_pattern) {
queryParams.push(spray_pattern);
query += ` AND spray_pattern = $${queryParams.length}`;
}
query += ' ORDER BY manufacturer, name';
const result = await pool.query(query, queryParams);
// Group by manufacturer for easier frontend handling
const nozzlesByManufacturer = result.rows.reduce((acc, nozzle) => {
const manufacturer = nozzle.manufacturer || 'Unknown';
if (!acc[manufacturer]) {
acc[manufacturer] = [];
}
acc[manufacturer].push({
id: nozzle.id,
name: nozzle.name,
manufacturer: nozzle.manufacturer,
model: nozzle.model,
orificeSize: nozzle.orifice_size,
sprayAngle: nozzle.spray_angle,
flowRateGpm: parseFloat(nozzle.flow_rate_gpm),
dropletSize: nozzle.droplet_size,
sprayPattern: nozzle.spray_pattern,
pressureRangePsi: nozzle.pressure_range_psi,
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,
flowRateGpm: parseFloat(nt.flow_rate_gpm),
dropletSize: nt.droplet_size,
sprayPattern: nt.spray_pattern,
pressureRangePsi: nt.pressure_range_psi,
createdAt: nt.created_at
})),
nozzlesByManufacturer
}
});
} 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);
}
});
module.exports = router;