Files
turftracker/backend/src/routes/applications.js
2025-08-29 10:38:48 -04:00

1458 lines
48 KiB
JavaScript

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.id as property_id, 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.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),
propertyId: plan.property_id, // Add property ID for frontend to use
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', 'archived'].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 {
console.log('Create log request received:', JSON.stringify(req.body, null, 2));
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
console.log('Inserting application log with values:', {
planId,
userId: req.user.id,
lawnSectionId,
equipmentId,
weatherConditions: JSON.stringify(weatherConditions),
gpsTrack: JSON.stringify(gpsTrack),
averageSpeed,
areaCovered,
notes
});
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];
console.log('Application log inserted successfully:', log);
// Add products to log
console.log('Adding products to log:', products);
for (const product of products) {
const {
productId,
userProductId,
rateAmount,
rateUnit,
actualProductAmount,
actualWaterAmount,
actualSpeedMph
} = product;
console.log('Inserting product:', {
logId: log.id, productId, userProductId, rateAmount, rateUnit,
actualProductAmount, actualWaterAmount, actualSpeedMph
});
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]
);
console.log('Product inserted successfully');
}
// 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);
}
});
// @route POST /api/applications/logs
// @desc Create application log (actual execution)
// @access Private
router.post('/logs', validateRequest(applicationLogSchema), async (req, res, next) => {
try {
const {
planId,
lawnSectionId,
equipmentId,
applicationDate,
weatherConditions,
gpsTrack,
averageSpeed,
areaCovered,
notes,
products
} = req.body;
// Verify plan belongs to user if planId is provided
if (planId) {
const planCheck = await pool.query(`
SELECT ap.* 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
WHERE ap.id = $1 AND p.user_id = $2
`, [planId, req.user.id]);
if (planCheck.rows.length === 0) {
throw new AppError('Plan not found', 404);
}
}
// Verify lawn section belongs to user
const sectionCheck = await pool.query(`
SELECT ls.* 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
const equipmentCheck = await pool.query(
'SELECT * 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 pool.query(`
INSERT INTO application_logs
(plan_id, user_id, lawn_section_id, equipment_id, application_date, weather_conditions, gps_track, average_speed, area_covered, notes)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
`, [
planId,
req.user.id,
lawnSectionId,
equipmentId,
applicationDate || new Date(),
weatherConditions ? JSON.stringify(weatherConditions) : null,
gpsTrack ? JSON.stringify(gpsTrack) : null,
averageSpeed,
areaCovered,
notes
]);
const log = logResult.rows[0];
// Create product log entries
if (products && products.length > 0) {
for (const product of products) {
// Verify product ownership if it's a user product
if (product.userProductId) {
const productCheck = await pool.query(
'SELECT * FROM user_products WHERE id = $1 AND user_id = $2',
[product.userProductId, req.user.id]
);
if (productCheck.rows.length === 0) {
throw new AppError(`User product ${product.userProductId} not found`, 404);
}
}
await pool.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,
product.productId || null,
product.userProductId || null,
product.rateAmount,
product.rateUnit,
product.actualProductAmount || null,
product.actualWaterAmount || null,
product.actualSpeedMph || null
]);
}
}
// Update plan status to completed if planId provided
if (planId) {
await pool.query(
'UPDATE application_plans SET status = $1 WHERE id = $2',
['completed', planId]
);
}
res.status(201).json({
success: true,
message: 'Application log created successfully',
data: {
log: {
id: log.id,
planId: log.plan_id,
lawnSectionId: log.lawn_section_id,
equipmentId: log.equipment_id,
applicationDate: log.application_date,
averageSpeed: log.average_speed,
areaCovered: log.area_covered,
createdAt: log.created_at
}
}
});
} catch (error) {
next(error);
}
});
// @route GET /api/applications/logs
// @desc Get application logs
// @access Private
router.get('/logs', async (req, res, next) => {
try {
const result = await pool.query(`
SELECT
al.*,
p.name as property_name,
p.address as property_address,
ls.name as section_name,
ue.custom_name as equipment_name,
et.name as equipment_type
FROM application_logs al
JOIN lawn_sections ls ON al.lawn_section_id = ls.id
JOIN properties p ON ls.property_id = p.id
JOIN user_equipment ue ON al.equipment_id = ue.id
LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id
WHERE p.user_id = $1
ORDER BY al.application_date DESC, al.created_at DESC
`, [req.user.id]);
const logs = result.rows.map(row => ({
id: row.id,
planId: row.plan_id,
propertyName: row.property_name,
propertyAddress: row.property_address,
sectionName: row.section_name,
equipmentName: row.equipment_name,
equipmentType: row.equipment_type,
applicationDate: row.application_date,
gpsTrack: row.gps_track,
averageSpeed: row.average_speed,
areaCovered: row.area_covered,
notes: row.notes,
createdAt: row.created_at
}));
res.json({
success: true,
data: {
logs
}
});
} catch (error) {
next(error);
}
});
module.exports = router;