Initial Claude Run
This commit is contained in:
590
backend/src/routes/applications.js
Normal file
590
backend/src/routes/applications.js
Normal file
@@ -0,0 +1,590 @@
|
||||
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 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, plannedDate, 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, 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, planned_date, notes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[req.user.id, lawnSectionId, equipmentId, plannedDate, notes]
|
||||
);
|
||||
|
||||
const plan = planResult.rows[0];
|
||||
|
||||
// Add products to plan with calculations
|
||||
for (const product of products) {
|
||||
const { productId, userProductId, rateAmount, rateUnit } = product;
|
||||
|
||||
// Calculate application amounts based on area and rate
|
||||
const sectionArea = parseFloat(section.area);
|
||||
let calculatedProductAmount = 0;
|
||||
let calculatedWaterAmount = 0;
|
||||
let targetSpeed = 3; // Default 3 MPH
|
||||
|
||||
// Basic calculation logic (can be enhanced based on equipment type)
|
||||
if (rateUnit.includes('1000sqft')) {
|
||||
calculatedProductAmount = rateAmount * (sectionArea / 1000);
|
||||
} else if (rateUnit.includes('acre')) {
|
||||
calculatedProductAmount = rateAmount * (sectionArea / 43560);
|
||||
} else {
|
||||
calculatedProductAmount = rateAmount;
|
||||
}
|
||||
|
||||
// Water calculation for liquid applications
|
||||
if (rateUnit.includes('gal')) {
|
||||
calculatedWaterAmount = calculatedProductAmount;
|
||||
} else if (rateUnit.includes('oz/gal')) {
|
||||
calculatedWaterAmount = sectionArea / 1000; // 1 gal per 1000 sqft default
|
||||
calculatedProductAmount = rateAmount * calculatedWaterAmount;
|
||||
}
|
||||
|
||||
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;
|
||||
Reference in New Issue
Block a user