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(); // @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 '); const result = await pool.query( `SELECT ap.*, 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(app.id) as product_count FROM application_plans ap JOIN lawn_sections ls ON ap.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 application_plan_products app ON ap.id = app.plan_id WHERE ${whereClause} GROUP BY ap.id, ls.name, ls.area, p.name, p.address, ue.custom_name, et.name ORDER BY ap.planned_date DESC, ap.created_at DESC`, queryParams ); res.json({ success: true, data: { plans: result.rows.map(plan => ({ id: plan.id, status: plan.status, plannedDate: plan.planned_date, notes: plan.notes, sectionName: plan.section_name, sectionArea: parseFloat(plan.section_area), propertyName: plan.property_name, propertyAddress: plan.property_address, equipmentName: plan.equipment_name || plan.equipment_type, productCount: parseInt(plan.product_count), createdAt: plan.created_at, updatedAt: plan.updated_at })) } }); } 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 const planResult = await pool.query( `SELECT ap.*, ls.name as section_name, ls.area as section_area, ls.polygon_data, 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 FROM application_plans ap JOIN lawn_sections ls ON ap.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 ap.id = $1 AND ap.user_id = $2`, [planId, req.user.id] ); 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] ); res.json({ success: true, data: { plan: { id: plan.id, status: plan.status, plannedDate: plan.planned_date, notes: plan.notes, section: { id: plan.lawn_section_id, name: plan.section_name, area: parseFloat(plan.section_area), polygonData: plan.polygon_data }, 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 }, 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, equipmentId, nozzleId, plannedDate, notes, products, areaSquareFeet, equipment, nozzle } = 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, ls.area, 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); } const section = sectionCheck.rows[0]; // Verify equipment belongs to user const equipmentCheck = await client.query( 'SELECT id, tank_size, pump_gpm, nozzle_gpm, nozzle_count 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]; // Create application plan const planResult = await client.query( `INSERT INTO application_plans (user_id, lawn_section_id, equipment_id, nozzle_id, planned_date, notes) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [req.user.id, lawnSectionId, equipmentId, nozzleId, plannedDate, notes] ); const plan = planResult.rows[0]; // Add products to plan with calculations for (const product of products) { const { productId, userProductId, rateAmount, rateUnit, applicationType } = product; // Use passed area or get from database const sectionArea = areaSquareFeet || parseFloat(section.area); // Perform advanced calculations using the calculation engine const calculations = calculateApplication({ areaSquareFeet: sectionArea, rateAmount: parseFloat(rateAmount), rateUnit, applicationType, equipment, nozzle }); console.log('Plan creation calculations:', calculations); // 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; calculatedWaterAmount = calculations.waterAmountGallons || 0; } 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'); 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 } } }); } 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); } }); module.exports = router;