Files
turftracker/backend/src/routes/applications.js
2025-08-23 13:18:53 -04:00

606 lines
19 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();
// @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;