const express = require('express'); const pool = require('../config/database'); const { validateRequest, validateParams } = require('../utils/validation'); const { applicationPlanSchema, applicationLogSchema, idParamSchema } = require('../utils/validation'); const { AppError } = require('../middleware/errorHandler'); const { calculateApplication } = require('../utils/applicationCalculations'); const router = express.Router(); // Helper function to get spreader settings for equipment and product async function getSpreaderSettingsForEquipment(equipmentId, productId, userProductId, userId) { // Get spreader settings for the specific product and equipment combination let query, params; if (userProductId) { query = ` SELECT pss.*, ue.custom_name as equipment_name, ue.manufacturer, ue.model as equipment_model FROM product_spreader_settings pss JOIN user_equipment ue ON pss.equipment_id = ue.id WHERE pss.user_product_id = $1 AND pss.equipment_id = $2 AND ue.user_id = $3 ORDER BY pss.created_at DESC LIMIT 1 `; params = [userProductId, equipmentId, userId]; } else { query = ` SELECT pss.*, ue.custom_name as equipment_name, ue.manufacturer, ue.model as equipment_model FROM product_spreader_settings pss JOIN user_equipment ue ON pss.equipment_id = ue.id WHERE pss.product_id = $1 AND pss.equipment_id = $2 AND ue.user_id = $3 ORDER BY pss.created_at DESC LIMIT 1 `; params = [productId, equipmentId, userId]; } const result = await pool.query(query, params); return result.rows[0] || null; } // @route GET /api/applications/plans // @desc Get all application plans for current user // @access Private router.get('/plans', async (req, res, next) => { try { const { status, upcoming, property_id } = req.query; let whereConditions = ['ap.user_id = $1']; let queryParams = [req.user.id]; let paramCount = 1; if (status) { paramCount++; whereConditions.push(`ap.status = $${paramCount}`); queryParams.push(status); } if (upcoming === 'true') { paramCount++; whereConditions.push(`ap.planned_date >= $${paramCount}`); queryParams.push(new Date().toISOString().split('T')[0]); } if (property_id) { paramCount++; whereConditions.push(`p.id = $${paramCount}`); queryParams.push(property_id); } const whereClause = whereConditions.join(' AND '); // First get basic plan info with sections const result = await pool.query( `SELECT ap.*, STRING_AGG(DISTINCT ls.name, ', ') as section_names, SUM(DISTINCT ls.area) as total_section_area, COUNT(DISTINCT aps.lawn_section_id) as section_count, p.name as property_name, p.address as property_address, ue.custom_name as equipment_name, et.name as equipment_type FROM application_plans ap JOIN application_plan_sections aps ON ap.id = aps.plan_id JOIN lawn_sections ls ON aps.lawn_section_id = ls.id JOIN properties p ON ls.property_id = p.id LEFT JOIN user_equipment ue ON ap.equipment_id = ue.id LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id WHERE ${whereClause} GROUP BY ap.id, p.name, p.address, ue.custom_name, et.name ORDER BY ap.planned_date DESC, ap.created_at DESC`, queryParams ); // Then get product info separately to avoid duplication const planIds = result.rows.map(row => row.id); let productInfo = {}; if (planIds.length > 0) { const productResult = await pool.query( `SELECT plan_id, COUNT(id) as product_count, SUM(calculated_product_amount) as total_product_amount, MAX(calculated_water_amount) as total_water_amount, AVG(target_speed_mph) as avg_speed_mph FROM application_plan_products WHERE plan_id = ANY($1) GROUP BY plan_id`, [planIds] ); productInfo = productResult.rows.reduce((acc, row) => { acc[row.plan_id] = row; return acc; }, {}); } // Get spreader settings and product details for each plan const plansWithSettings = await Promise.all( result.rows.map(async (plan) => { let spreaderSetting = null; let productDetails = []; // Get all products for this plan const productsResult = await pool.query( `SELECT app.*, COALESCE(p.name, up.custom_name) as product_name, COALESCE(p.brand, up.custom_brand) as product_brand, COALESCE(p.product_type, up.custom_product_type) as product_type, p.name as shared_name, up.custom_name as user_name FROM application_plan_products app LEFT JOIN products p ON app.product_id = p.id LEFT JOIN user_products up ON app.user_product_id = up.id WHERE app.plan_id = $1`, [plan.id] ); productDetails = productsResult.rows.map(product => ({ name: product.product_name, brand: product.product_brand, type: product.product_type, rateAmount: parseFloat(product.rate_amount || 0), rateUnit: product.rate_unit, calculatedAmount: parseFloat(product.calculated_product_amount || 0), isShared: !!product.shared_name })); // Only get spreader settings for granular applications with equipment if (plan.equipment_name && productsResult.rows.length > 0) { const firstProduct = productsResult.rows[0]; console.log('Checking spreader settings for plan:', { planId: plan.id, equipmentName: plan.equipment_name, productInfo: { productId: firstProduct.product_id, userProductId: firstProduct.user_product_id, sharedName: firstProduct.shared_name, productType: firstProduct.product_type } }); const productType = firstProduct.product_type; console.log('Detected product type:', productType); if (productType === 'granular') { // Get equipment ID const equipmentResult = await pool.query( 'SELECT id FROM user_equipment WHERE custom_name = $1 AND user_id = $2', [plan.equipment_name, req.user.id] ); console.log('Equipment lookup result:', { equipmentName: plan.equipment_name, userId: req.user.id, foundEquipment: equipmentResult.rows.length > 0, equipmentId: equipmentResult.rows[0]?.id }); if (equipmentResult.rows.length > 0) { const equipmentId = equipmentResult.rows[0].id; spreaderSetting = await getSpreaderSettingsForEquipment( equipmentId, firstProduct.product_id, firstProduct.user_product_id, req.user.id ); console.log('Spreader setting lookup result:', { equipmentId, productId: firstProduct.product_id, userProductId: firstProduct.user_product_id, setting: spreaderSetting }); } } } const planProductInfo = productInfo[plan.id] || {}; return { id: plan.id, status: plan.status, plannedDate: plan.planned_date, notes: plan.notes, sectionNames: plan.section_names, // Multiple section names comma-separated sectionCount: parseInt(plan.section_count), totalSectionArea: parseFloat(plan.total_section_area), propertyName: plan.property_name, propertyAddress: plan.property_address, equipmentName: plan.equipment_name || plan.equipment_type, productCount: parseInt(planProductInfo.product_count || 0), totalProductAmount: parseFloat(planProductInfo.total_product_amount || 0), totalWaterAmount: parseFloat(planProductInfo.total_water_amount || 0), avgSpeedMph: parseFloat(planProductInfo.avg_speed_mph || 0), spreaderSetting: spreaderSetting?.setting_value || null, productDetails: productDetails, createdAt: plan.created_at, updatedAt: plan.updated_at }; }) ); res.json({ success: true, data: { plans: plansWithSettings } }); } catch (error) { next(error); } }); // @route GET /api/applications/plans/:id // @desc Get single application plan with products // @access Private router.get('/plans/:id', validateParams(idParamSchema), async (req, res, next) => { try { const planId = req.params.id; // Get plan details with all sections const planResult = await pool.query( `SELECT ap.*, p.id as property_id, p.name as property_name, p.address as property_address, ue.id as equipment_id, ue.custom_name as equipment_name, et.name as equipment_type, et.category as equipment_category, nz.id as nozzle_id, nz.custom_name as nozzle_name, nz.flow_rate_gpm, nz.spray_angle FROM application_plans ap JOIN application_plan_sections aps ON ap.id = aps.plan_id JOIN lawn_sections ls ON aps.lawn_section_id = ls.id JOIN properties p ON ls.property_id = p.id LEFT JOIN user_equipment ue ON ap.equipment_id = ue.id LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id LEFT JOIN user_equipment nz ON ap.nozzle_id = nz.id WHERE ap.id = $1 AND ap.user_id = $2 LIMIT 1`, [planId, req.user.id] ); // Get sections for this plan separately const sectionsResult = await pool.query( `SELECT ls.id, ls.name, ls.area, ls.polygon_data FROM application_plan_sections aps JOIN lawn_sections ls ON aps.lawn_section_id = ls.id WHERE aps.plan_id = $1 ORDER BY ls.name`, [planId] ); if (planResult.rows.length === 0) { throw new AppError('Application plan not found', 404); } const plan = planResult.rows[0]; // Get plan products const productsResult = await pool.query( `SELECT app.*, COALESCE(up.custom_name, p.name) as product_name, COALESCE(p.brand, '') as product_brand, COALESCE(p.product_type, 'unknown') as product_type FROM application_plan_products app LEFT JOIN products p ON app.product_id = p.id LEFT JOIN user_products up ON app.user_product_id = up.id WHERE app.plan_id = $1 ORDER BY app.id`, [planId] ); const sections = sectionsResult.rows.map(section => ({ id: section.id, name: section.name, area: parseFloat(section.area), polygonData: section.polygon_data })); const totalArea = sections.reduce((sum, section) => sum + section.area, 0); res.json({ success: true, data: { plan: { id: plan.id, status: plan.status, plannedDate: plan.planned_date, notes: plan.notes, sections: sections, // Array of sections instead of single section totalArea: totalArea, property: { id: plan.property_id, name: plan.property_name, address: plan.property_address }, equipment: { id: plan.equipment_id, name: plan.equipment_name || plan.equipment_type, type: plan.equipment_type, category: plan.equipment_category }, nozzle: plan.nozzle_id ? { id: plan.nozzle_id, name: plan.nozzle_name, flowRateGpm: plan.flow_rate_gpm, sprayAngle: plan.spray_angle } : null, products: productsResult.rows.map(product => ({ id: product.id, productId: product.product_id, userProductId: product.user_product_id, productName: product.product_name, productBrand: product.product_brand, productType: product.product_type, rateAmount: parseFloat(product.rate_amount), rateUnit: product.rate_unit, calculatedProductAmount: parseFloat(product.calculated_product_amount), calculatedWaterAmount: parseFloat(product.calculated_water_amount), targetSpeedMph: parseFloat(product.target_speed_mph) })), createdAt: plan.created_at, updatedAt: plan.updated_at } } }); } catch (error) { next(error); } }); // @route POST /api/applications/plans // @desc Create new application plan // @access Private router.post('/plans', validateRequest(applicationPlanSchema), async (req, res, next) => { try { const { lawnSectionId, lawnSectionIds, // New multi-area support equipmentId, nozzleId, plannedDate, notes, products, areaSquareFeet, equipment, nozzle } = req.body; // Handle both single and multiple lawn sections const sectionIds = lawnSectionIds || [lawnSectionId]; // Start transaction const client = await pool.connect(); try { await client.query('BEGIN'); // Verify all lawn sections belong to user and are from same property const sectionCheck = await client.query( `SELECT ls.id, ls.area, p.user_id, p.id as property_id FROM lawn_sections ls JOIN properties p ON ls.property_id = p.id WHERE ls.id = ANY($1) AND p.user_id = $2`, [sectionIds, req.user.id] ); if (sectionCheck.rows.length !== sectionIds.length) { throw new AppError('One or more lawn sections not found', 404); } // Ensure all sections are from the same property const propertyIds = [...new Set(sectionCheck.rows.map(row => row.property_id))]; if (propertyIds.length > 1) { throw new AppError('All sections must be from the same property', 400); } const sections = sectionCheck.rows; const totalArea = areaSquareFeet || sections.reduce((sum, section) => sum + parseFloat(section.area), 0); // Verify equipment belongs to user and get equipment details with category const equipmentCheck = await client.query( `SELECT ue.*, et.name as type_name, 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 COALESCE(et.category_id, ue.category_id) = ec.id WHERE ue.id = $1 AND ue.user_id = $2`, [equipmentId, req.user.id] ); if (equipmentCheck.rows.length === 0) { throw new AppError('Equipment not found', 404); } const equipmentData = equipmentCheck.rows[0]; // Get nozzle data if provided let nozzleData = null; if (nozzleId) { const nozzleCheck = await client.query( 'SELECT * FROM user_equipment WHERE id = $1 AND user_id = $2', [nozzleId, req.user.id] ); if (nozzleCheck.rows.length > 0) { nozzleData = nozzleCheck.rows[0]; } } console.log('Creating plan with data:', { userId: req.user.id, sectionIds, equipmentId, nozzleId, plannedDate, notes }); // Create application plan (no longer has lawn_section_id column) const planResult = await client.query( `INSERT INTO application_plans (user_id, equipment_id, nozzle_id, planned_date, notes) VALUES ($1, $2, $3, $4, $5) RETURNING *`, [req.user.id, equipmentId, nozzleId, plannedDate, notes] ); const plan = planResult.rows[0]; // Create section associations in junction table for (const sectionId of sectionIds) { await client.query( `INSERT INTO application_plan_sections (plan_id, lawn_section_id) VALUES ($1, $2)`, [plan.id, sectionId] ); } // Calculate shared water amount and speed for liquid applications using total area const sectionArea = totalArea; const firstProduct = products[0]; const isLiquid = firstProduct.applicationType === 'liquid'; let sharedWaterAmount = 0; let sharedTargetSpeed = 3; if (isLiquid) { // Prepare equipment and nozzle objects for water calculation const equipmentForCalc = { categoryName: equipmentData.category_name, tankSizeGallons: equipmentData.tank_size_gallons, sprayWidthFeet: equipmentData.spray_width_feet, capacityLbs: equipmentData.capacity_lbs, spreadWidth: equipmentData.spread_width }; const nozzleForCalc = nozzleData ? { flowRateGpm: nozzleData.flow_rate_gpm, sprayAngle: nozzleData.spray_angle } : null; // Calculate water and speed once for the entire application const waterCalculation = calculateApplication({ areaSquareFeet: sectionArea, rateAmount: 1, // Use dummy rate for water calculation rateUnit: 'oz/1000 sq ft', applicationType: 'liquid', equipment: equipmentForCalc, nozzle: nozzleForCalc }); sharedWaterAmount = waterCalculation.waterAmountGallons || 0; sharedTargetSpeed = waterCalculation.applicationSpeedMph || 3; console.log('Shared liquid calculation:', { sectionArea, sharedWaterAmount, sharedTargetSpeed, productsCount: products.length }); } // Add products to plan with calculations for (const product of products) { const { productId, userProductId, rateAmount, rateUnit, applicationType } = product; // Prepare equipment object for calculations const equipmentForCalc = { categoryName: equipmentData.category_name, tankSizeGallons: equipmentData.tank_size_gallons, sprayWidthFeet: equipmentData.spray_width_feet, capacityLbs: equipmentData.capacity_lbs, spreadWidth: equipmentData.spread_width }; // Prepare nozzle object for calculations const nozzleForCalc = nozzleData ? { flowRateGpm: nozzleData.flow_rate_gpm, sprayAngle: nozzleData.spray_angle } : null; // Calculate product amount const calculations = calculateApplication({ areaSquareFeet: sectionArea, rateAmount: parseFloat(rateAmount), rateUnit, applicationType, equipment: equipmentForCalc, nozzle: nozzleForCalc }); console.log('Individual product calculation:', { product: productId || userProductId, rateAmount, rateUnit, calculatedAmount: calculations.productAmountOunces || calculations.productAmountPounds }); // Extract calculated values based on application type let calculatedProductAmount = 0; let calculatedWaterAmount = 0; let targetSpeed = calculations.applicationSpeedMph || 3; if (calculations.type === 'liquid') { calculatedProductAmount = calculations.productAmountOunces || 0; // Use shared water amount for liquid applications calculatedWaterAmount = sharedWaterAmount; targetSpeed = sharedTargetSpeed; } else if (calculations.type === 'granular') { calculatedProductAmount = calculations.productAmountPounds || 0; calculatedWaterAmount = 0; // No water for granular } await client.query( `INSERT INTO application_plan_products (plan_id, product_id, user_product_id, rate_amount, rate_unit, calculated_product_amount, calculated_water_amount, target_speed_mph) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [plan.id, productId, userProductId, rateAmount, rateUnit, calculatedProductAmount, calculatedWaterAmount, targetSpeed] ); } await client.query('COMMIT'); // Get the created plan with calculations for response const createdPlanResult = await client.query( `SELECT ap.*, app.calculated_product_amount, app.calculated_water_amount, app.target_speed_mph, app.rate_amount, app.rate_unit FROM application_plans ap LEFT JOIN application_plan_products app ON ap.id = app.plan_id WHERE ap.id = $1`, [plan.id] ); res.status(201).json({ success: true, message: 'Application plan created successfully', data: { plan: { id: plan.id, status: plan.status, plannedDate: plan.planned_date, notes: plan.notes, createdAt: plan.created_at, calculations: createdPlanResult.rows.map(row => ({ productAmount: row.calculated_product_amount, waterAmount: row.calculated_water_amount, targetSpeed: row.target_speed_mph, rateAmount: row.rate_amount, rateUnit: row.rate_unit })) } } }); } catch (error) { console.error('Plan creation transaction error:', error); await client.query('ROLLBACK'); throw error; } finally { client.release(); } } catch (error) { console.error('Plan creation error:', error); next(error); } }); // @route PUT /api/applications/plans/:id // @desc Update application plan (supports multiple areas) // @access Private router.put('/plans/:id', validateParams(idParamSchema), validateRequest(applicationPlanSchema), async (req, res, next) => { try { const planId = req.params.id; const { lawnSectionId, lawnSectionIds, // New multi-area support equipmentId, nozzleId, plannedDate, notes, products, areaSquareFeet, equipment, nozzle } = req.body; // Handle both single and multiple lawn sections const sectionIds = lawnSectionIds || [lawnSectionId]; const client = await pool.connect(); try { await client.query('BEGIN'); // Check if plan belongs to user const planCheck = await client.query( 'SELECT id FROM application_plans WHERE id = $1 AND user_id = $2', [planId, req.user.id] ); if (planCheck.rows.length === 0) { throw new AppError('Application plan not found', 404); } // Verify all new lawn sections belong to user and are from same property const sectionCheck = await client.query( `SELECT ls.id, ls.area, p.user_id, p.id as property_id FROM lawn_sections ls JOIN properties p ON ls.property_id = p.id WHERE ls.id = ANY($1) AND p.user_id = $2`, [sectionIds, req.user.id] ); if (sectionCheck.rows.length !== sectionIds.length) { throw new AppError('One or more lawn sections not found', 404); } // Ensure all sections are from the same property const propertyIds = [...new Set(sectionCheck.rows.map(row => row.property_id))]; if (propertyIds.length > 1) { throw new AppError('All sections must be from the same property', 400); } const sections = sectionCheck.rows; const totalArea = areaSquareFeet || sections.reduce((sum, section) => sum + parseFloat(section.area), 0); // Verify equipment belongs to user and get equipment details const equipmentCheck = await client.query( `SELECT ue.*, et.name as type_name, 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 COALESCE(et.category_id, ue.category_id) = ec.id WHERE ue.id = $1 AND ue.user_id = $2`, [equipmentId, req.user.id] ); if (equipmentCheck.rows.length === 0) { throw new AppError('Equipment not found', 404); } const equipmentData = equipmentCheck.rows[0]; // Get nozzle data if provided let nozzleData = null; if (nozzleId) { const nozzleCheck = await client.query( 'SELECT * FROM user_equipment WHERE id = $1 AND user_id = $2', [nozzleId, req.user.id] ); if (nozzleCheck.rows.length > 0) { nozzleData = nozzleCheck.rows[0]; } } // Update application plan (no longer has lawn_section_id column) const updateResult = await client.query( `UPDATE application_plans SET equipment_id = $1, nozzle_id = $2, planned_date = $3, notes = $4, updated_at = CURRENT_TIMESTAMP WHERE id = $5 RETURNING *`, [equipmentId, nozzleId, plannedDate, notes, planId] ); const plan = updateResult.rows[0]; // Update section associations - delete old ones and add new ones await client.query('DELETE FROM application_plan_sections WHERE plan_id = $1', [planId]); for (const sectionId of sectionIds) { await client.query( `INSERT INTO application_plan_sections (plan_id, lawn_section_id) VALUES ($1, $2)`, [plan.id, sectionId] ); } // Delete existing products await client.query('DELETE FROM application_plan_products WHERE plan_id = $1', [planId]); // Calculate shared water amount and speed for liquid applications using total area const sectionArea = totalArea; const firstProduct = products[0]; const isLiquid = firstProduct.applicationType === 'liquid'; let sharedWaterAmount = 0; let sharedTargetSpeed = 3; if (isLiquid) { // Prepare equipment and nozzle objects for water calculation const equipmentForCalc = { categoryName: equipmentData.category_name, tankSizeGallons: equipmentData.tank_size_gallons, sprayWidthFeet: equipmentData.spray_width_feet, capacityLbs: equipmentData.capacity_lbs, spreadWidth: equipmentData.spread_width }; const nozzleForCalc = nozzleData ? { flowRateGpm: nozzleData.flow_rate_gpm, sprayAngle: nozzleData.spray_angle } : null; // Calculate water and speed once for the entire application const waterCalculation = calculateApplication({ areaSquareFeet: sectionArea, rateAmount: 1, // Use dummy rate for water calculation rateUnit: 'oz/1000 sq ft', applicationType: 'liquid', equipment: equipmentForCalc, nozzle: nozzleForCalc }); sharedWaterAmount = waterCalculation.waterAmountGallons || 0; sharedTargetSpeed = waterCalculation.applicationSpeedMph || 3; } // Add updated products with recalculation for (const product of products) { const { productId, userProductId, rateAmount, rateUnit, applicationType } = product; // Prepare equipment object for calculations const equipmentForCalc = { categoryName: equipmentData.category_name, tankSizeGallons: equipmentData.tank_size_gallons, sprayWidthFeet: equipmentData.spray_width_feet, capacityLbs: equipmentData.capacity_lbs, spreadWidth: equipmentData.spread_width }; // Prepare nozzle object for calculations const nozzleForCalc = nozzleData ? { flowRateGpm: nozzleData.flow_rate_gpm, sprayAngle: nozzleData.spray_angle } : null; // Perform recalculation const calculations = calculateApplication({ areaSquareFeet: sectionArea, rateAmount: parseFloat(rateAmount), rateUnit, applicationType, equipment: equipmentForCalc, nozzle: nozzleForCalc }); // Extract calculated values let calculatedProductAmount = 0; let calculatedWaterAmount = 0; let targetSpeed = calculations.applicationSpeedMph || 3; if (calculations.type === 'liquid') { calculatedProductAmount = calculations.productAmountOunces || 0; // Use shared water amount for liquid applications calculatedWaterAmount = sharedWaterAmount; targetSpeed = sharedTargetSpeed; } else if (calculations.type === 'granular') { calculatedProductAmount = calculations.productAmountPounds || 0; calculatedWaterAmount = 0; } await client.query( `INSERT INTO application_plan_products (plan_id, product_id, user_product_id, rate_amount, rate_unit, calculated_product_amount, calculated_water_amount, target_speed_mph) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [plan.id, productId, userProductId, rateAmount, rateUnit, calculatedProductAmount, calculatedWaterAmount, targetSpeed] ); } await client.query('COMMIT'); res.json({ success: true, message: 'Application plan updated successfully', data: { plan: { id: plan.id, status: plan.status, plannedDate: plan.planned_date, notes: plan.notes, updatedAt: plan.updated_at } } }); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } catch (error) { next(error); } }); // @route DELETE /api/applications/plans/:id // @desc Delete application plan // @access Private router.delete('/plans/:id', validateParams(idParamSchema), async (req, res, next) => { try { const planId = req.params.id; const client = await pool.connect(); try { await client.query('BEGIN'); // Check if plan belongs to user const planCheck = await client.query( 'SELECT id FROM application_plans WHERE id = $1 AND user_id = $2', [planId, req.user.id] ); if (planCheck.rows.length === 0) { throw new AppError('Application plan not found', 404); } // Delete plan products first (due to foreign key constraint) await client.query('DELETE FROM application_plan_products WHERE plan_id = $1', [planId]); // Delete the plan await client.query('DELETE FROM application_plans WHERE id = $1', [planId]); await client.query('COMMIT'); res.json({ success: true, message: 'Application plan deleted successfully' }); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } catch (error) { next(error); } }); // @route PUT /api/applications/plans/:id/status // @desc Update application plan status // @access Private router.put('/plans/:id/status', validateParams(idParamSchema), async (req, res, next) => { try { const planId = req.params.id; const { status } = req.body; if (!['planned', 'in_progress', 'completed', 'cancelled'].includes(status)) { throw new AppError('Invalid status', 400); } // Check if plan belongs to user const checkResult = await pool.query( 'SELECT id, status FROM application_plans WHERE id = $1 AND user_id = $2', [planId, req.user.id] ); if (checkResult.rows.length === 0) { throw new AppError('Application plan not found', 404); } const result = await pool.query( `UPDATE application_plans SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *`, [status, planId] ); const plan = result.rows[0]; res.json({ success: true, message: 'Plan status updated successfully', data: { plan: { id: plan.id, status: plan.status, updatedAt: plan.updated_at } } }); } catch (error) { next(error); } }); // @route GET /api/applications/logs // @desc Get application logs for current user // @access Private router.get('/logs', async (req, res, next) => { try { const { property_id, start_date, end_date, limit = 50 } = req.query; let whereConditions = ['al.user_id = $1']; let queryParams = [req.user.id]; let paramCount = 1; if (property_id) { paramCount++; whereConditions.push(`p.id = $${paramCount}`); queryParams.push(property_id); } if (start_date) { paramCount++; whereConditions.push(`al.application_date >= $${paramCount}`); queryParams.push(start_date); } if (end_date) { paramCount++; whereConditions.push(`al.application_date <= $${paramCount}`); queryParams.push(end_date); } const whereClause = whereConditions.join(' AND '); paramCount++; queryParams.push(limit); const result = await pool.query( `SELECT al.*, ls.name as section_name, ls.area as section_area, p.name as property_name, p.address as property_address, ue.custom_name as equipment_name, et.name as equipment_type, COUNT(alp.id) as product_count FROM application_logs al JOIN lawn_sections ls ON al.lawn_section_id = ls.id JOIN properties p ON ls.property_id = p.id LEFT JOIN user_equipment ue ON al.equipment_id = ue.id LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id LEFT JOIN application_log_products alp ON al.id = alp.log_id WHERE ${whereClause} GROUP BY al.id, ls.name, ls.area, p.name, p.address, ue.custom_name, et.name ORDER BY al.application_date DESC LIMIT $${paramCount}`, queryParams ); res.json({ success: true, data: { logs: result.rows.map(log => ({ id: log.id, planId: log.plan_id, applicationDate: log.application_date, weatherConditions: log.weather_conditions, averageSpeed: parseFloat(log.average_speed), areaCovered: parseFloat(log.area_covered), notes: log.notes, sectionName: log.section_name, sectionArea: parseFloat(log.section_area), propertyName: log.property_name, propertyAddress: log.property_address, equipmentName: log.equipment_name || log.equipment_type, productCount: parseInt(log.product_count), createdAt: log.created_at })) } }); } catch (error) { next(error); } }); // @route POST /api/applications/logs // @desc Create application log // @access Private router.post('/logs', validateRequest(applicationLogSchema), async (req, res, next) => { try { const { planId, lawnSectionId, equipmentId, weatherConditions, gpsTrack, averageSpeed, areaCovered, notes, products } = req.body; // Start transaction const client = await pool.connect(); try { await client.query('BEGIN'); // Verify lawn section belongs to user const sectionCheck = await client.query( `SELECT ls.id, p.user_id FROM lawn_sections ls JOIN properties p ON ls.property_id = p.id WHERE ls.id = $1 AND p.user_id = $2`, [lawnSectionId, req.user.id] ); if (sectionCheck.rows.length === 0) { throw new AppError('Lawn section not found', 404); } // Verify equipment belongs to user (if provided) if (equipmentId) { const equipmentCheck = await client.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); } } // Create application log const logResult = await client.query( `INSERT INTO application_logs (plan_id, user_id, lawn_section_id, equipment_id, weather_conditions, gps_track, average_speed, area_covered, notes) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`, [planId, req.user.id, lawnSectionId, equipmentId, JSON.stringify(weatherConditions), JSON.stringify(gpsTrack), averageSpeed, areaCovered, notes] ); const log = logResult.rows[0]; // Add products to log for (const product of products) { const { productId, userProductId, rateAmount, rateUnit, actualProductAmount, actualWaterAmount, actualSpeedMph } = product; await client.query( `INSERT INTO application_log_products (log_id, product_id, user_product_id, rate_amount, rate_unit, actual_product_amount, actual_water_amount, actual_speed_mph) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [log.id, productId, userProductId, rateAmount, rateUnit, actualProductAmount, actualWaterAmount, actualSpeedMph] ); } // If this was from a plan, mark the plan as completed if (planId) { await client.query( 'UPDATE application_plans SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', ['completed', planId] ); } await client.query('COMMIT'); res.status(201).json({ success: true, message: 'Application logged successfully', data: { log: { id: log.id, applicationDate: log.application_date, createdAt: log.created_at } } }); } catch (error) { await client.query('ROLLBACK'); throw error; } finally { client.release(); } } catch (error) { next(error); } }); // @route GET /api/applications/stats // @desc Get application statistics // @access Private router.get('/stats', async (req, res, next) => { try { const { year = new Date().getFullYear() } = req.query; const statsQuery = ` SELECT COUNT(DISTINCT al.id) as total_applications, COUNT(DISTINCT ap.id) as total_plans, COUNT(DISTINCT CASE WHEN ap.status = 'completed' THEN ap.id END) as completed_plans, COUNT(DISTINCT CASE WHEN ap.status = 'planned' THEN ap.id END) as planned_applications, COALESCE(SUM(al.area_covered), 0) as total_area_treated, COALESCE(AVG(al.average_speed), 0) as avg_application_speed FROM application_logs al FULL OUTER JOIN application_plans ap ON al.plan_id = ap.id OR ap.user_id = $1 WHERE EXTRACT(YEAR FROM COALESCE(al.application_date, ap.planned_date)) = $2 AND (al.user_id = $1 OR ap.user_id = $1) `; const statsResult = await pool.query(statsQuery, [req.user.id, year]); const stats = statsResult.rows[0]; // Get monthly breakdown const monthlyQuery = ` SELECT EXTRACT(MONTH FROM al.application_date) as month, COUNT(*) as applications, COALESCE(SUM(al.area_covered), 0) as area_covered FROM application_logs al WHERE al.user_id = $1 AND EXTRACT(YEAR FROM al.application_date) = $2 GROUP BY EXTRACT(MONTH FROM al.application_date) ORDER BY month `; const monthlyResult = await pool.query(monthlyQuery, [req.user.id, year]); res.json({ success: true, data: { stats: { totalApplications: parseInt(stats.total_applications) || 0, totalPlans: parseInt(stats.total_plans) || 0, completedPlans: parseInt(stats.completed_plans) || 0, plannedApplications: parseInt(stats.planned_applications) || 0, totalAreaTreated: parseFloat(stats.total_area_treated) || 0, avgApplicationSpeed: parseFloat(stats.avg_application_speed) || 0, completionRate: stats.total_plans > 0 ? Math.round((stats.completed_plans / stats.total_plans) * 100) : 0 }, monthlyBreakdown: monthlyResult.rows.map(row => ({ month: parseInt(row.month), applications: parseInt(row.applications), areaCovered: parseFloat(row.area_covered) })) } }); } catch (error) { next(error); } }); // @route GET /api/applications/spreader-settings/:equipmentId/:productId // @desc Get recommended spreader settings for specific equipment and product combination // @access Private router.get('/spreader-settings/:equipmentId/:productId', async (req, res, next) => { try { const { equipmentId, productId } = req.params; const { isUserProduct } = req.query; // Indicates if productId refers to user_products table // Verify equipment belongs to user const equipmentCheck = await pool.query( 'SELECT id, custom_name, manufacturer, model 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 equipment = equipmentCheck.rows[0]; // Get spreader settings const spreaderSetting = await getSpreaderSettingsForEquipment( parseInt(equipmentId), isUserProduct === 'true' ? null : parseInt(productId), isUserProduct === 'true' ? parseInt(productId) : null, req.user.id ); if (!spreaderSetting) { return res.json({ success: true, data: { hasSettings: false, message: 'No spreader settings found for this equipment and product combination', equipment: { id: equipment.id, name: equipment.custom_name || `${equipment.manufacturer} ${equipment.model}`.trim() } } }); } res.json({ success: true, data: { hasSettings: true, setting: { id: spreaderSetting.id, settingValue: spreaderSetting.setting_value, rateDescription: spreaderSetting.rate_description, notes: spreaderSetting.notes }, equipment: { id: equipment.id, name: equipment.custom_name || `${equipment.manufacturer} ${equipment.model}`.trim() } } }); } catch (error) { next(error); } }); module.exports = router;