1458 lines
48 KiB
JavaScript
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; |