Initial Claude Run

This commit is contained in:
Jake Kasper
2025-08-21 07:06:36 -05:00
parent 5ead64afcd
commit 2a46f7261e
53 changed files with 7633 additions and 2 deletions

31
backend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S turftracker -u 1001
# Change ownership of the app directory
RUN chown -R turftracker:nodejs /app
USER turftracker
# Expose port
EXPOSE 5000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node healthcheck.js
# Start the application
CMD ["npm", "start"]

28
backend/healthcheck.js Normal file
View File

@@ -0,0 +1,28 @@
const http = require('http');
const options = {
hostname: 'localhost',
port: 5000,
path: '/health',
method: 'GET',
timeout: 2000
};
const req = http.request(options, (res) => {
if (res.statusCode === 200) {
process.exit(0);
} else {
process.exit(1);
}
});
req.on('error', () => {
process.exit(1);
});
req.on('timeout', () => {
req.destroy();
process.exit(1);
});
req.end();

43
backend/package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "turftracker-backend",
"version": "1.0.0",
"description": "Backend API for TurfTracker lawn care management application",
"main": "src/app.js",
"scripts": {
"start": "node src/app.js",
"dev": "nodemon src/app.js",
"test": "jest"
},
"dependencies": {
"express": "^4.18.2",
"pg": "^8.11.3",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"express-rate-limit": "^7.1.5",
"dotenv": "^16.3.1",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"multer": "^1.4.5-lts.1",
"axios": "^1.6.2",
"joi": "^17.11.0",
"morgan": "^1.10.0",
"compression": "^1.7.4"
},
"devDependencies": {
"nodemon": "^3.0.2",
"jest": "^29.7.0",
"supertest": "^6.3.3"
},
"keywords": [
"lawn care",
"agriculture",
"tracking",
"fertilizer",
"turf management"
],
"author": "TurfTracker Team",
"license": "MIT"
}

115
backend/src/app.js Normal file
View File

@@ -0,0 +1,115 @@
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const compression = require('compression');
const rateLimit = require('express-rate-limit');
require('dotenv').config();
const authRoutes = require('./routes/auth');
const userRoutes = require('./routes/users');
const propertyRoutes = require('./routes/properties');
const equipmentRoutes = require('./routes/equipment');
const productRoutes = require('./routes/products');
const applicationRoutes = require('./routes/applications');
const weatherRoutes = require('./routes/weather');
const adminRoutes = require('./routes/admin');
const errorHandler = require('./middleware/errorHandler');
const { authenticateToken } = require('./middleware/auth');
const app = express();
const PORT = process.env.PORT || 5000;
// Trust proxy for rate limiting
app.set('trust proxy', 1);
// Security middleware
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://maps.googleapis.com"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com"],
imgSrc: ["'self'", "data:", "https://maps.googleapis.com", "https://maps.gstatic.com"],
connectSrc: ["'self'", "https://api.openweathermap.org"]
}
}
}));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests from this IP, please try again later.',
standardHeaders: true,
legacyHeaders: false,
});
app.use(limiter);
// Stricter rate limiting for auth routes
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // Limit each IP to 5 auth requests per windowMs
message: 'Too many authentication attempts, please try again later.',
standardHeaders: true,
legacyHeaders: false,
});
// Middleware
app.use(compression());
app.use(cors({
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
credentials: true
}));
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(morgan('combined'));
// Health check endpoint
app.get('/health', (req, res) => {
res.status(200).json({
status: 'OK',
timestamp: new Date().toISOString(),
uptime: process.uptime(),
environment: process.env.NODE_ENV || 'development'
});
});
// Routes
app.use('/api/auth', authLimiter, authRoutes);
app.use('/api/users', authenticateToken, userRoutes);
app.use('/api/properties', authenticateToken, propertyRoutes);
app.use('/api/equipment', authenticateToken, equipmentRoutes);
app.use('/api/products', authenticateToken, productRoutes);
app.use('/api/applications', authenticateToken, applicationRoutes);
app.use('/api/weather', authenticateToken, weatherRoutes);
app.use('/api/admin', authenticateToken, adminRoutes);
// 404 handler
app.use('*', (req, res) => {
res.status(404).json({
success: false,
message: 'API endpoint not found'
});
});
// Global error handler
app.use(errorHandler);
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
process.exit(0);
});
process.on('SIGINT', () => {
console.log('SIGINT received, shutting down gracefully');
process.exit(0);
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`TurfTracker API server running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});

View File

@@ -0,0 +1,38 @@
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
// Test the connection
pool.on('connect', () => {
console.log('Connected to PostgreSQL database');
});
pool.on('error', (err) => {
console.error('Database connection error:', err);
process.exit(-1);
});
// Graceful shutdown
process.on('SIGINT', () => {
console.log('Closing database connections...');
pool.end(() => {
console.log('Database connections closed.');
process.exit(0);
});
});
process.on('SIGTERM', () => {
console.log('Closing database connections...');
pool.end(() => {
console.log('Database connections closed.');
process.exit(0);
});
});
module.exports = pool;

View File

@@ -0,0 +1,75 @@
const jwt = require('jsonwebtoken');
const pool = require('../config/database');
const authenticateToken = async (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({
success: false,
message: 'Access token required'
});
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Verify user still exists and is active
const userResult = await pool.query(
'SELECT id, email, role FROM users WHERE id = $1',
[decoded.userId]
);
if (userResult.rows.length === 0) {
return res.status(401).json({
success: false,
message: 'Invalid token - user not found'
});
}
req.user = userResult.rows[0];
next();
} catch (error) {
console.error('Token verification error:', error);
return res.status(403).json({
success: false,
message: 'Invalid or expired token'
});
}
};
const requireAdmin = (req, res, next) => {
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Admin access required'
});
}
next();
};
const requireOwnership = (resourceUserIdField = 'user_id') => {
return (req, res, next) => {
const resourceUserId = req.params[resourceUserIdField] || req.body[resourceUserIdField];
if (req.user.role === 'admin') {
return next(); // Admins can access any resource
}
if (parseInt(resourceUserId) !== req.user.id) {
return res.status(403).json({
success: false,
message: 'Access denied - you can only access your own resources'
});
}
next();
};
};
module.exports = {
authenticateToken,
requireAdmin,
requireOwnership
};

View File

@@ -0,0 +1,97 @@
const errorHandler = (err, req, res, next) => {
console.error('Error occurred:', {
message: err.message,
stack: err.stack,
url: req.url,
method: req.method,
ip: req.ip,
userAgent: req.get('User-Agent')
});
// Default error
let error = {
success: false,
message: 'Internal server error'
};
// Validation error
if (err.isJoi) {
error.message = 'Validation error';
error.details = err.details.map(detail => detail.message);
return res.status(400).json(error);
}
// JWT errors
if (err.name === 'JsonWebTokenError') {
error.message = 'Invalid token';
return res.status(401).json(error);
}
if (err.name === 'TokenExpiredError') {
error.message = 'Token expired';
return res.status(401).json(error);
}
// PostgreSQL errors
if (err.code) {
switch (err.code) {
case '23505': // Unique violation
error.message = 'Duplicate entry - resource already exists';
return res.status(409).json(error);
case '23503': // Foreign key violation
error.message = 'Invalid reference - related resource not found';
return res.status(400).json(error);
case '23502': // Not null violation
error.message = 'Missing required field';
return res.status(400).json(error);
case '42P01': // Undefined table
error.message = 'Database configuration error';
return res.status(500).json(error);
default:
error.message = 'Database error occurred';
return res.status(500).json(error);
}
}
// Custom application errors
if (err.statusCode) {
error.message = err.message || 'Application error';
return res.status(err.statusCode).json(error);
}
// File upload errors
if (err.code === 'LIMIT_FILE_SIZE') {
error.message = 'File too large';
return res.status(413).json(error);
}
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
error.message = 'Unexpected file field';
return res.status(400).json(error);
}
// Network/timeout errors
if (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT') {
error.message = 'External service unavailable';
return res.status(503).json(error);
}
// Default 500 error
res.status(500).json(error);
};
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.isOperational = true;
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = { errorHandler, AppError };

529
backend/src/routes/admin.js Normal file
View File

@@ -0,0 +1,529 @@
const express = require('express');
const pool = require('../config/database');
const { validateRequest, validateParams } = require('../utils/validation');
const { productSchema, idParamSchema } = require('../utils/validation');
const { requireAdmin } = require('../middleware/auth');
const { AppError } = require('../middleware/errorHandler');
const router = express.Router();
// Apply admin middleware to all routes
router.use(requireAdmin);
// @route GET /api/admin/dashboard
// @desc Get admin dashboard statistics
// @access Private (Admin)
router.get('/dashboard', async (req, res, next) => {
try {
const statsQuery = `
SELECT
(SELECT COUNT(*) FROM users) as total_users,
(SELECT COUNT(*) FROM users WHERE role = 'admin') as admin_users,
(SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '30 days') as new_users_30d,
(SELECT COUNT(*) FROM properties) as total_properties,
(SELECT COUNT(*) FROM user_equipment) as total_equipment,
(SELECT COUNT(*) FROM products) as total_products,
(SELECT COUNT(*) FROM user_products) as custom_products,
(SELECT COUNT(*) FROM application_plans) as total_plans,
(SELECT COUNT(*) FROM application_logs) as total_applications,
(SELECT COUNT(*) FROM application_logs WHERE application_date >= CURRENT_DATE - INTERVAL '7 days') as recent_applications
`;
const statsResult = await pool.query(statsQuery);
const stats = statsResult.rows[0];
// Get user activity (users with recent activity)
const userActivityQuery = `
SELECT
DATE_TRUNC('day', created_at) as date,
COUNT(*) as new_registrations
FROM users
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY DATE_TRUNC('day', created_at)
ORDER BY date DESC
`;
const activityResult = await pool.query(userActivityQuery);
// Get application activity
const applicationActivityQuery = `
SELECT
DATE_TRUNC('day', application_date) as date,
COUNT(*) as applications,
COUNT(DISTINCT user_id) as active_users
FROM application_logs
WHERE application_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY DATE_TRUNC('day', application_date)
ORDER BY date DESC
`;
const appActivityResult = await pool.query(applicationActivityQuery);
res.json({
success: true,
data: {
stats: {
totalUsers: parseInt(stats.total_users),
adminUsers: parseInt(stats.admin_users),
newUsers30d: parseInt(stats.new_users_30d),
totalProperties: parseInt(stats.total_properties),
totalEquipment: parseInt(stats.total_equipment),
totalProducts: parseInt(stats.total_products),
customProducts: parseInt(stats.custom_products),
totalPlans: parseInt(stats.total_plans),
totalApplications: parseInt(stats.total_applications),
recentApplications: parseInt(stats.recent_applications)
},
userActivity: activityResult.rows.map(row => ({
date: row.date,
newRegistrations: parseInt(row.new_registrations)
})),
applicationActivity: appActivityResult.rows.map(row => ({
date: row.date,
applications: parseInt(row.applications),
activeUsers: parseInt(row.active_users)
}))
}
});
} catch (error) {
next(error);
}
});
// @route GET /api/admin/users
// @desc Get all users with pagination
// @access Private (Admin)
router.get('/users', async (req, res, next) => {
try {
const { page = 1, limit = 20, search, role } = req.query;
const offset = (page - 1) * limit;
let whereConditions = [];
let queryParams = [];
let paramCount = 0;
if (search) {
paramCount++;
whereConditions.push(`(first_name ILIKE $${paramCount} OR last_name ILIKE $${paramCount} OR email ILIKE $${paramCount})`);
queryParams.push(`%${search}%`);
}
if (role) {
paramCount++;
whereConditions.push(`role = $${paramCount}`);
queryParams.push(role);
}
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
// Get total count
const countQuery = `SELECT COUNT(*) as total FROM users ${whereClause}`;
const countResult = await pool.query(countQuery, queryParams);
const totalUsers = parseInt(countResult.rows[0].total);
// Get users with stats
paramCount++;
queryParams.push(limit);
paramCount++;
queryParams.push(offset);
const usersQuery = `
SELECT u.id, u.email, u.first_name, u.last_name, u.role, u.oauth_provider,
u.created_at, u.updated_at,
COUNT(DISTINCT p.id) as property_count,
COUNT(DISTINCT ap.id) as application_count,
MAX(al.application_date) as last_application
FROM users u
LEFT JOIN properties p ON u.id = p.user_id
LEFT JOIN application_plans ap ON u.id = ap.user_id
LEFT JOIN application_logs al ON u.id = al.user_id
${whereClause}
GROUP BY u.id
ORDER BY u.created_at DESC
LIMIT $${paramCount - 1} OFFSET $${paramCount}
`;
const usersResult = await pool.query(usersQuery, queryParams);
res.json({
success: true,
data: {
users: usersResult.rows.map(user => ({
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role,
oauthProvider: user.oauth_provider,
propertyCount: parseInt(user.property_count),
applicationCount: parseInt(user.application_count),
lastApplication: user.last_application,
createdAt: user.created_at,
updatedAt: user.updated_at
})),
pagination: {
currentPage: parseInt(page),
totalPages: Math.ceil(totalUsers / limit),
totalUsers,
hasNext: (page * limit) < totalUsers,
hasPrev: page > 1
}
}
});
} catch (error) {
next(error);
}
});
// @route PUT /api/admin/users/:id/role
// @desc Update user role
// @access Private (Admin)
router.put('/users/:id/role', validateParams(idParamSchema), async (req, res, next) => {
try {
const userId = req.params.id;
const { role } = req.body;
if (!['admin', 'user'].includes(role)) {
throw new AppError('Invalid role', 400);
}
// Prevent removing admin role from yourself
if (parseInt(userId) === req.user.id && role !== 'admin') {
throw new AppError('Cannot remove admin role from yourself', 400);
}
// Check if user exists
const userCheck = await pool.query(
'SELECT id, role FROM users WHERE id = $1',
[userId]
);
if (userCheck.rows.length === 0) {
throw new AppError('User not found', 404);
}
// Update role
const result = await pool.query(
'UPDATE users SET role = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING id, email, role',
[role, userId]
);
const user = result.rows[0];
res.json({
success: true,
message: 'User role updated successfully',
data: {
user: {
id: user.id,
email: user.email,
role: user.role
}
}
});
} catch (error) {
next(error);
}
});
// @route DELETE /api/admin/users/:id
// @desc Delete user account
// @access Private (Admin)
router.delete('/users/:id', validateParams(idParamSchema), async (req, res, next) => {
try {
const userId = req.params.id;
// Prevent deleting yourself
if (parseInt(userId) === req.user.id) {
throw new AppError('Cannot delete your own account', 400);
}
// Check if user exists
const userCheck = await pool.query(
'SELECT id, email FROM users WHERE id = $1',
[userId]
);
if (userCheck.rows.length === 0) {
throw new AppError('User not found', 404);
}
const user = userCheck.rows[0];
// Delete user (cascading will handle related records)
await pool.query('DELETE FROM users WHERE id = $1', [userId]);
res.json({
success: true,
message: `User ${user.email} deleted successfully`
});
} catch (error) {
next(error);
}
});
// @route GET /api/admin/products
// @desc Get all products for management
// @access Private (Admin)
router.get('/products', async (req, res, next) => {
try {
const { category, search } = req.query;
let whereConditions = [];
let queryParams = [];
let paramCount = 0;
if (category) {
paramCount++;
whereConditions.push(`p.category_id = $${paramCount}`);
queryParams.push(category);
}
if (search) {
paramCount++;
whereConditions.push(`(p.name ILIKE $${paramCount} OR p.brand ILIKE $${paramCount})`);
queryParams.push(`%${search}%`);
}
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
const result = await pool.query(
`SELECT p.*, pc.name as category_name,
COUNT(pr.id) as rate_count,
COUNT(up.id) as usage_count
FROM products p
JOIN product_categories pc ON p.category_id = pc.id
LEFT JOIN product_rates pr ON p.id = pr.product_id
LEFT JOIN user_products up ON p.id = up.product_id
${whereClause}
GROUP BY p.id, pc.name
ORDER BY p.name`,
queryParams
);
res.json({
success: true,
data: {
products: result.rows.map(product => ({
id: product.id,
name: product.name,
brand: product.brand,
categoryName: product.category_name,
productType: product.product_type,
activeIngredients: product.active_ingredients,
description: product.description,
rateCount: parseInt(product.rate_count),
usageCount: parseInt(product.usage_count),
createdAt: product.created_at
}))
}
});
} catch (error) {
next(error);
}
});
// @route POST /api/admin/products
// @desc Create new product
// @access Private (Admin)
router.post('/products', validateRequest(productSchema), async (req, res, next) => {
try {
const { name, brand, categoryId, productType, activeIngredients, description } = req.body;
// Check if category exists
const categoryCheck = await pool.query(
'SELECT id FROM product_categories WHERE id = $1',
[categoryId]
);
if (categoryCheck.rows.length === 0) {
throw new AppError('Product category not found', 404);
}
const result = await pool.query(
`INSERT INTO products (name, brand, category_id, product_type, active_ingredients, description)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[name, brand, categoryId, productType, activeIngredients, description]
);
const product = result.rows[0];
res.status(201).json({
success: true,
message: 'Product created successfully',
data: {
product: {
id: product.id,
name: product.name,
brand: product.brand,
categoryId: product.category_id,
productType: product.product_type,
activeIngredients: product.active_ingredients,
description: product.description,
createdAt: product.created_at
}
}
});
} catch (error) {
next(error);
}
});
// @route PUT /api/admin/products/:id
// @desc Update product
// @access Private (Admin)
router.put('/products/:id', validateParams(idParamSchema), validateRequest(productSchema), async (req, res, next) => {
try {
const productId = req.params.id;
const { name, brand, categoryId, productType, activeIngredients, description } = req.body;
// Check if product exists
const productCheck = await pool.query(
'SELECT id FROM products WHERE id = $1',
[productId]
);
if (productCheck.rows.length === 0) {
throw new AppError('Product not found', 404);
}
// Check if category exists
const categoryCheck = await pool.query(
'SELECT id FROM product_categories WHERE id = $1',
[categoryId]
);
if (categoryCheck.rows.length === 0) {
throw new AppError('Product category not found', 404);
}
const result = await pool.query(
`UPDATE products
SET name = $1, brand = $2, category_id = $3, product_type = $4,
active_ingredients = $5, description = $6
WHERE id = $7
RETURNING *`,
[name, brand, categoryId, productType, activeIngredients, description, productId]
);
const product = result.rows[0];
res.json({
success: true,
message: 'Product updated successfully',
data: {
product: {
id: product.id,
name: product.name,
brand: product.brand,
categoryId: product.category_id,
productType: product.product_type,
activeIngredients: product.active_ingredients,
description: product.description,
createdAt: product.created_at
}
}
});
} catch (error) {
next(error);
}
});
// @route DELETE /api/admin/products/:id
// @desc Delete product
// @access Private (Admin)
router.delete('/products/:id', validateParams(idParamSchema), async (req, res, next) => {
try {
const productId = req.params.id;
// Check if product exists
const productCheck = await pool.query(
'SELECT id, name FROM products WHERE id = $1',
[productId]
);
if (productCheck.rows.length === 0) {
throw new AppError('Product not found', 404);
}
const product = productCheck.rows[0];
// Check if product is used in any user products or applications
const usageCheck = await pool.query(
`SELECT
(SELECT COUNT(*) FROM user_products WHERE product_id = $1) +
(SELECT COUNT(*) FROM application_plan_products WHERE product_id = $1) +
(SELECT COUNT(*) FROM application_log_products WHERE product_id = $1) as usage_count`,
[productId]
);
if (parseInt(usageCheck.rows[0].usage_count) > 0) {
throw new AppError('Cannot delete product that is being used by users', 400);
}
await pool.query('DELETE FROM products WHERE id = $1', [productId]);
res.json({
success: true,
message: `Product "${product.name}" deleted successfully`
});
} catch (error) {
next(error);
}
});
// @route GET /api/admin/system/health
// @desc Get system health information
// @access Private (Admin)
router.get('/system/health', async (req, res, next) => {
try {
// Database connection test
const dbResult = await pool.query('SELECT NOW() as timestamp, version() as version');
// Get database statistics
const dbStats = await pool.query(`
SELECT
pg_size_pretty(pg_database_size(current_database())) as database_size,
(SELECT count(*) FROM pg_stat_activity WHERE state = 'active') as active_connections,
(SELECT setting FROM pg_settings WHERE name = 'max_connections') as max_connections
`);
const stats = dbStats.rows[0];
res.json({
success: true,
data: {
system: {
status: 'healthy',
timestamp: new Date(),
uptime: process.uptime(),
nodeVersion: process.version,
environment: process.env.NODE_ENV || 'development'
},
database: {
status: 'connected',
version: dbResult.rows[0].version,
size: stats.database_size,
activeConnections: parseInt(stats.active_connections),
maxConnections: parseInt(stats.max_connections),
timestamp: dbResult.rows[0].timestamp
},
services: {
weatherApi: {
configured: !!process.env.WEATHER_API_KEY,
status: process.env.WEATHER_API_KEY ? 'available' : 'not configured'
},
googleOAuth: {
configured: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
status: (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) ? 'available' : 'not configured'
}
}
}
});
} catch (error) {
next(error);
}
});
module.exports = router;

View 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;

313
backend/src/routes/auth.js Normal file
View File

@@ -0,0 +1,313 @@
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const passport = require('passport');
const GoogleStrategy = require('passport-google-oauth20').Strategy;
const pool = require('../config/database');
const { validateRequest } = require('../utils/validation');
const { registerSchema, loginSchema, changePasswordSchema } = require('../utils/validation');
const { AppError } = require('../middleware/errorHandler');
const router = express.Router();
// Configure Google OAuth2 strategy
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
passport.use(new GoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: '/api/auth/google/callback'
}, async (accessToken, refreshToken, profile, done) => {
try {
// Check if user already exists
const existingUser = await pool.query(
'SELECT * FROM users WHERE oauth_provider = $1 AND oauth_id = $2',
['google', profile.id]
);
if (existingUser.rows.length > 0) {
return done(null, existingUser.rows[0]);
}
// Check if user exists with same email
const emailUser = await pool.query(
'SELECT * FROM users WHERE email = $1',
[profile.emails[0].value]
);
if (emailUser.rows.length > 0) {
// Link Google account to existing user
await pool.query(
'UPDATE users SET oauth_provider = $1, oauth_id = $2 WHERE id = $3',
['google', profile.id, emailUser.rows[0].id]
);
return done(null, emailUser.rows[0]);
}
// Create new user
const newUser = await pool.query(
`INSERT INTO users (email, first_name, last_name, oauth_provider, oauth_id)
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
[
profile.emails[0].value,
profile.name.givenName,
profile.name.familyName,
'google',
profile.id
]
);
return done(null, newUser.rows[0]);
} catch (error) {
return done(error, null);
}
}));
}
passport.serializeUser((user, done) => {
done(null, user.id);
});
passport.deserializeUser(async (id, done) => {
try {
const user = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
done(null, user.rows[0]);
} catch (error) {
done(error, null);
}
});
// Generate JWT token
const generateToken = (userId) => {
return jwt.sign(
{ userId },
process.env.JWT_SECRET,
{ expiresIn: '7d' }
);
};
// @route POST /api/auth/register
// @desc Register a new user
// @access Public
router.post('/register', validateRequest(registerSchema), async (req, res, next) => {
try {
const { email, password, firstName, lastName } = req.body;
// Check if user already exists
const existingUser = await pool.query(
'SELECT id FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (existingUser.rows.length > 0) {
throw new AppError('User with this email already exists', 409);
}
// Hash password
const saltRounds = 12;
const passwordHash = await bcrypt.hash(password, saltRounds);
// Create user
const newUser = await pool.query(
`INSERT INTO users (email, password_hash, first_name, last_name)
VALUES ($1, $2, $3, $4) RETURNING id, email, first_name, last_name, role`,
[email.toLowerCase(), passwordHash, firstName, lastName]
);
const user = newUser.rows[0];
const token = generateToken(user.id);
res.status(201).json({
success: true,
message: 'User registered successfully',
data: {
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role
},
token
}
});
} catch (error) {
next(error);
}
});
// @route POST /api/auth/login
// @desc Login user
// @access Public
router.post('/login', validateRequest(loginSchema), async (req, res, next) => {
try {
const { email, password } = req.body;
// Find user
const userResult = await pool.query(
'SELECT id, email, password_hash, first_name, last_name, role FROM users WHERE email = $1',
[email.toLowerCase()]
);
if (userResult.rows.length === 0) {
throw new AppError('Invalid email or password', 401);
}
const user = userResult.rows[0];
// Check password
if (!user.password_hash) {
throw new AppError('Please use Google sign-in for this account', 401);
}
const isValidPassword = await bcrypt.compare(password, user.password_hash);
if (!isValidPassword) {
throw new AppError('Invalid email or password', 401);
}
const token = generateToken(user.id);
res.json({
success: true,
message: 'Login successful',
data: {
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role
},
token
}
});
} catch (error) {
next(error);
}
});
// @route GET /api/auth/google
// @desc Start Google OAuth2 flow
// @access Public
router.get('/google', passport.authenticate('google', {
scope: ['profile', 'email']
}));
// @route GET /api/auth/google/callback
// @desc Google OAuth2 callback
// @access Public
router.get('/google/callback',
passport.authenticate('google', { session: false }),
(req, res) => {
const token = generateToken(req.user.id);
// Redirect to frontend with token
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
res.redirect(`${frontendUrl}/auth/callback?token=${token}`);
}
);
// @route POST /api/auth/change-password
// @desc Change user password
// @access Private
router.post('/change-password', validateRequest(changePasswordSchema), async (req, res, next) => {
try {
const { currentPassword, newPassword } = req.body;
const userId = req.user.id;
// Get current user
const userResult = await pool.query(
'SELECT password_hash FROM users WHERE id = $1',
[userId]
);
const user = userResult.rows[0];
if (!user.password_hash) {
throw new AppError('Cannot change password for OAuth accounts', 400);
}
// Verify current password
const isValidPassword = await bcrypt.compare(currentPassword, user.password_hash);
if (!isValidPassword) {
throw new AppError('Current password is incorrect', 401);
}
// Hash new password
const saltRounds = 12;
const newPasswordHash = await bcrypt.hash(newPassword, saltRounds);
// Update password
await pool.query(
'UPDATE users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
[newPasswordHash, userId]
);
res.json({
success: true,
message: 'Password changed successfully'
});
} catch (error) {
next(error);
}
});
// @route POST /api/auth/forgot-password
// @desc Request password reset
// @access Public
router.post('/forgot-password', async (req, res, next) => {
try {
const { email } = req.body;
// Check if user exists
const userResult = await pool.query(
'SELECT id, first_name FROM users WHERE email = $1',
[email.toLowerCase()]
);
// Always return success for security (don't reveal if email exists)
res.json({
success: true,
message: 'If an account with that email exists, a password reset link has been sent'
});
// TODO: Implement email sending logic
// In a real application, you would:
// 1. Generate a secure reset token
// 2. Store it in database with expiration
// 3. Send email with reset link
} catch (error) {
next(error);
}
});
// @route GET /api/auth/me
// @desc Get current user info
// @access Private
router.get('/me', async (req, res, next) => {
try {
const userResult = await pool.query(
'SELECT id, email, first_name, last_name, role, created_at FROM users WHERE id = $1',
[req.user.id]
);
const user = userResult.rows[0];
res.json({
success: true,
data: {
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role,
createdAt: user.created_at
}
}
});
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -0,0 +1,448 @@
const express = require('express');
const pool = require('../config/database');
const { validateRequest, validateParams } = require('../utils/validation');
const { equipmentSchema, idParamSchema } = require('../utils/validation');
const { AppError } = require('../middleware/errorHandler');
const router = express.Router();
// @route GET /api/equipment/types
// @desc Get all equipment types
// @access Private
router.get('/types', async (req, res, next) => {
try {
const result = await pool.query(
'SELECT * FROM equipment_types ORDER BY category, name'
);
const equipmentByCategory = result.rows.reduce((acc, equipment) => {
if (!acc[equipment.category]) {
acc[equipment.category] = [];
}
acc[equipment.category].push({
id: equipment.id,
name: equipment.name,
category: equipment.category,
createdAt: equipment.created_at
});
return acc;
}, {});
res.json({
success: true,
data: {
equipmentTypes: result.rows,
equipmentByCategory
}
});
} catch (error) {
next(error);
}
});
// @route GET /api/equipment
// @desc Get all equipment for current user
// @access Private
router.get('/', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT ue.*, et.name as type_name, et.category
FROM user_equipment ue
JOIN equipment_types et ON ue.equipment_type_id = et.id
WHERE ue.user_id = $1
ORDER BY et.category, et.name`,
[req.user.id]
);
res.json({
success: true,
data: {
equipment: result.rows.map(item => ({
id: item.id,
equipmentTypeId: item.equipment_type_id,
typeName: item.type_name,
category: item.category,
customName: item.custom_name,
tankSize: parseFloat(item.tank_size),
pumpGpm: parseFloat(item.pump_gpm),
nozzleGpm: parseFloat(item.nozzle_gpm),
nozzleCount: item.nozzle_count,
spreaderWidth: parseFloat(item.spreader_width),
createdAt: item.created_at,
updatedAt: item.updated_at
}))
}
});
} catch (error) {
next(error);
}
});
// @route GET /api/equipment/:id
// @desc Get single equipment item
// @access Private
router.get('/:id', validateParams(idParamSchema), async (req, res, next) => {
try {
const equipmentId = req.params.id;
const result = await pool.query(
`SELECT ue.*, et.name as type_name, et.category
FROM user_equipment ue
JOIN equipment_types et ON ue.equipment_type_id = et.id
WHERE ue.id = $1 AND ue.user_id = $2`,
[equipmentId, req.user.id]
);
if (result.rows.length === 0) {
throw new AppError('Equipment not found', 404);
}
const item = result.rows[0];
res.json({
success: true,
data: {
equipment: {
id: item.id,
equipmentTypeId: item.equipment_type_id,
typeName: item.type_name,
category: item.category,
customName: item.custom_name,
tankSize: parseFloat(item.tank_size),
pumpGpm: parseFloat(item.pump_gpm),
nozzleGpm: parseFloat(item.nozzle_gpm),
nozzleCount: item.nozzle_count,
spreaderWidth: parseFloat(item.spreader_width),
createdAt: item.created_at,
updatedAt: item.updated_at
}
}
});
} catch (error) {
next(error);
}
});
// @route POST /api/equipment
// @desc Add new equipment
// @access Private
router.post('/', validateRequest(equipmentSchema), async (req, res, next) => {
try {
const {
equipmentTypeId,
customName,
tankSize,
pumpGpm,
nozzleGpm,
nozzleCount,
spreaderWidth
} = req.body;
// Verify equipment type exists
const typeCheck = await pool.query(
'SELECT id, name, category FROM equipment_types WHERE id = $1',
[equipmentTypeId]
);
if (typeCheck.rows.length === 0) {
throw new AppError('Equipment type not found', 404);
}
const equipmentType = typeCheck.rows[0];
// Validate required fields based on equipment type
if (equipmentType.category === 'sprayer') {
if (!tankSize || !pumpGpm || !nozzleGpm || !nozzleCount) {
throw new AppError('Tank size, pump GPM, nozzle GPM, and nozzle count are required for sprayers', 400);
}
}
if (equipmentType.category === 'spreader' && !spreaderWidth) {
throw new AppError('Spreader width is required for spreaders', 400);
}
const result = await pool.query(
`INSERT INTO user_equipment
(user_id, equipment_type_id, custom_name, tank_size, pump_gpm, nozzle_gpm, nozzle_count, spreader_width)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *`,
[req.user.id, equipmentTypeId, customName, tankSize, pumpGpm, nozzleGpm, nozzleCount, spreaderWidth]
);
const equipment = result.rows[0];
res.status(201).json({
success: true,
message: 'Equipment added successfully',
data: {
equipment: {
id: equipment.id,
equipmentTypeId: equipment.equipment_type_id,
typeName: equipmentType.name,
category: equipmentType.category,
customName: equipment.custom_name,
tankSize: parseFloat(equipment.tank_size),
pumpGpm: parseFloat(equipment.pump_gpm),
nozzleGpm: parseFloat(equipment.nozzle_gpm),
nozzleCount: equipment.nozzle_count,
spreaderWidth: parseFloat(equipment.spreader_width),
createdAt: equipment.created_at,
updatedAt: equipment.updated_at
}
}
});
} catch (error) {
next(error);
}
});
// @route PUT /api/equipment/:id
// @desc Update equipment
// @access Private
router.put('/:id', validateParams(idParamSchema), validateRequest(equipmentSchema), async (req, res, next) => {
try {
const equipmentId = req.params.id;
const {
equipmentTypeId,
customName,
tankSize,
pumpGpm,
nozzleGpm,
nozzleCount,
spreaderWidth
} = req.body;
// Check if equipment exists and belongs to user
const checkResult = await pool.query(
'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2',
[equipmentId, req.user.id]
);
if (checkResult.rows.length === 0) {
throw new AppError('Equipment not found', 404);
}
// Verify equipment type exists
const typeCheck = await pool.query(
'SELECT id, name, category FROM equipment_types WHERE id = $1',
[equipmentTypeId]
);
if (typeCheck.rows.length === 0) {
throw new AppError('Equipment type not found', 404);
}
const equipmentType = typeCheck.rows[0];
// Validate required fields based on equipment type
if (equipmentType.category === 'sprayer') {
if (!tankSize || !pumpGpm || !nozzleGpm || !nozzleCount) {
throw new AppError('Tank size, pump GPM, nozzle GPM, and nozzle count are required for sprayers', 400);
}
}
if (equipmentType.category === 'spreader' && !spreaderWidth) {
throw new AppError('Spreader width is required for spreaders', 400);
}
const result = await pool.query(
`UPDATE user_equipment
SET equipment_type_id = $1, custom_name = $2, tank_size = $3, pump_gpm = $4,
nozzle_gpm = $5, nozzle_count = $6, spreader_width = $7, updated_at = CURRENT_TIMESTAMP
WHERE id = $8
RETURNING *`,
[equipmentTypeId, customName, tankSize, pumpGpm, nozzleGpm, nozzleCount, spreaderWidth, equipmentId]
);
const equipment = result.rows[0];
res.json({
success: true,
message: 'Equipment updated successfully',
data: {
equipment: {
id: equipment.id,
equipmentTypeId: equipment.equipment_type_id,
typeName: equipmentType.name,
category: equipmentType.category,
customName: equipment.custom_name,
tankSize: parseFloat(equipment.tank_size),
pumpGpm: parseFloat(equipment.pump_gpm),
nozzleGpm: parseFloat(equipment.nozzle_gpm),
nozzleCount: equipment.nozzle_count,
spreaderWidth: parseFloat(equipment.spreader_width),
createdAt: equipment.created_at,
updatedAt: equipment.updated_at
}
}
});
} catch (error) {
next(error);
}
});
// @route DELETE /api/equipment/:id
// @desc Delete equipment
// @access Private
router.delete('/:id', validateParams(idParamSchema), async (req, res, next) => {
try {
const equipmentId = req.params.id;
// Check if equipment exists and belongs to user
const checkResult = await pool.query(
'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2',
[equipmentId, req.user.id]
);
if (checkResult.rows.length === 0) {
throw new AppError('Equipment not found', 404);
}
// Check if equipment is used in any applications
const usageCheck = await pool.query(
`SELECT COUNT(*) as count
FROM application_plans
WHERE equipment_id = $1 AND status IN ('planned', 'in_progress')`,
[equipmentId]
);
if (parseInt(usageCheck.rows[0].count) > 0) {
throw new AppError('Cannot delete equipment with active applications', 400);
}
await pool.query('DELETE FROM user_equipment WHERE id = $1', [equipmentId]);
res.json({
success: true,
message: 'Equipment deleted successfully'
});
} catch (error) {
next(error);
}
});
// @route GET /api/equipment/:id/calculations
// @desc Get application calculations for equipment
// @access Private
router.get('/:id/calculations', validateParams(idParamSchema), async (req, res, next) => {
try {
const equipmentId = req.params.id;
const { area, rateAmount, rateUnit } = req.query;
if (!area || !rateAmount || !rateUnit) {
throw new AppError('Area, rate amount, and rate unit are required for calculations', 400);
}
// Get equipment details
const equipmentResult = await pool.query(
`SELECT ue.*, et.category
FROM user_equipment ue
JOIN equipment_types et ON ue.equipment_type_id = et.id
WHERE ue.id = $1 AND ue.user_id = $2`,
[equipmentId, req.user.id]
);
if (equipmentResult.rows.length === 0) {
throw new AppError('Equipment not found', 404);
}
const equipment = equipmentResult.rows[0];
const targetArea = parseFloat(area);
const rate = parseFloat(rateAmount);
let calculations = {};
if (equipment.category === 'sprayer') {
// Liquid application calculations
const tankSize = parseFloat(equipment.tank_size);
const pumpGpm = parseFloat(equipment.pump_gpm);
const nozzleGpm = parseFloat(equipment.nozzle_gpm);
const nozzleCount = parseInt(equipment.nozzle_count);
// Calculate total nozzle output
const totalNozzleGpm = nozzleGpm * nozzleCount;
let productAmount, waterAmount, targetSpeed;
if (rateUnit.includes('gal/1000sqft') || rateUnit.includes('gal/acre')) {
// Gallons per area - calculate water volume needed
const multiplier = rateUnit.includes('acre') ? targetArea / 43560 : targetArea / 1000;
waterAmount = rate * multiplier;
productAmount = 0; // Pure water application
} else if (rateUnit.includes('oz/gal/1000sqft')) {
// Ounces per gallon per 1000 sqft
const waterGallonsNeeded = targetArea / 1000; // 1 gallon per 1000 sqft default
productAmount = rate * waterGallonsNeeded;
waterAmount = waterGallonsNeeded * 128; // Convert to ounces
} else {
// Default liquid calculation
productAmount = rate * (targetArea / 1000);
waterAmount = tankSize * 128; // Tank capacity in ounces
}
// Calculate target speed (assuming 20 foot spray width as default)
const sprayWidth = 20; // feet
const minutesToCover = waterAmount / (totalNozzleGpm * 128); // Convert GPM to oz/min
const distanceFeet = targetArea / sprayWidth;
targetSpeed = (distanceFeet / minutesToCover) * (60 / 5280); // Convert to MPH
calculations = {
productAmount: Math.round(productAmount * 100) / 100,
waterAmount: Math.round(waterAmount * 100) / 100,
targetSpeed: Math.round(targetSpeed * 100) / 100,
tankCount: Math.ceil(waterAmount / (tankSize * 128)),
applicationType: 'liquid',
unit: rateUnit.includes('oz') ? 'oz' : 'gal'
};
} else if (equipment.category === 'spreader') {
// Granular application calculations
const spreaderWidth = parseFloat(equipment.spreader_width);
let productAmount, targetSpeed;
if (rateUnit.includes('lbs/1000sqft')) {
productAmount = rate * (targetArea / 1000);
} else if (rateUnit.includes('lbs/acre')) {
productAmount = rate * (targetArea / 43560);
} else {
productAmount = rate * (targetArea / 1000); // Default to per 1000 sqft
}
// Calculate target speed (assuming 3 MPH walking speed as baseline)
const baselineSpeed = 3; // MPH
const minutesToSpread = 60; // Assume 1 hour coverage
const distanceFeet = targetArea / spreaderWidth;
targetSpeed = (distanceFeet / (minutesToSpread * 60)) * (60 / 5280); // Convert to MPH
calculations = {
productAmount: Math.round(productAmount * 100) / 100,
targetSpeed: Math.round(targetSpeed * 100) / 100,
applicationType: 'granular',
unit: 'lbs',
coverageTime: Math.round((targetArea / (spreaderWidth * baselineSpeed * 5280 / 60)) * 100) / 100
};
}
res.json({
success: true,
data: {
calculations,
equipment: {
id: equipment.id,
category: equipment.category,
tankSize: equipment.tank_size,
spreaderWidth: equipment.spreader_width
},
inputs: {
area: targetArea,
rate: rate,
unit: rateUnit
}
}
});
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -0,0 +1,537 @@
const express = require('express');
const pool = require('../config/database');
const { validateRequest, validateParams } = require('../utils/validation');
const { productSchema, productRateSchema, userProductSchema, idParamSchema } = require('../utils/validation');
const { AppError } = require('../middleware/errorHandler');
const router = express.Router();
// @route GET /api/products/categories
// @desc Get all product categories
// @access Private
router.get('/categories', async (req, res, next) => {
try {
const result = await pool.query(
'SELECT * FROM product_categories ORDER BY name'
);
res.json({
success: true,
data: {
categories: result.rows
}
});
} catch (error) {
next(error);
}
});
// @route GET /api/products
// @desc Get all products (shared + user's custom products)
// @access Private
router.get('/', async (req, res, next) => {
try {
const { category, type, search } = req.query;
let whereConditions = [];
let queryParams = [req.user.id];
let paramCount = 1;
// Build WHERE clause for filtering
if (category) {
paramCount++;
whereConditions.push(`pc.id = $${paramCount}`);
queryParams.push(category);
}
if (type) {
paramCount++;
whereConditions.push(`p.product_type = $${paramCount}`);
queryParams.push(type);
}
if (search) {
paramCount++;
whereConditions.push(`(p.name ILIKE $${paramCount} OR p.brand ILIKE $${paramCount} OR p.active_ingredients ILIKE $${paramCount})`);
queryParams.push(`%${search}%`);
}
const whereClause = whereConditions.length > 0 ? `AND ${whereConditions.join(' AND ')}` : '';
// Get shared products
const sharedProductsQuery = `
SELECT p.*, pc.name as category_name,
array_agg(
json_build_object(
'id', pr.id,
'applicationType', pr.application_type,
'rateAmount', pr.rate_amount,
'rateUnit', pr.rate_unit,
'notes', pr.notes
)
) FILTER (WHERE pr.id IS NOT NULL) as rates
FROM products p
JOIN product_categories pc ON p.category_id = pc.id
LEFT JOIN product_rates pr ON p.id = pr.product_id
WHERE 1=1 ${whereClause}
GROUP BY p.id, pc.name
ORDER BY p.name
`;
const sharedResult = await pool.query(sharedProductsQuery, queryParams.slice(1));
// Get user's custom products
const userProductsQuery = `
SELECT up.*, p.name as base_product_name, p.brand, p.product_type, pc.name as category_name
FROM user_products up
LEFT JOIN products p ON up.product_id = p.id
LEFT JOIN product_categories pc ON p.category_id = pc.id
WHERE up.user_id = $1
ORDER BY COALESCE(up.custom_name, p.name)
`;
const userResult = await pool.query(userProductsQuery, [req.user.id]);
res.json({
success: true,
data: {
sharedProducts: sharedResult.rows.map(product => ({
id: product.id,
name: product.name,
brand: product.brand,
categoryName: product.category_name,
productType: product.product_type,
activeIngredients: product.active_ingredients,
description: product.description,
rates: product.rates || [],
isShared: true,
createdAt: product.created_at
})),
userProducts: userResult.rows.map(product => ({
id: product.id,
baseProductId: product.product_id,
baseProductName: product.base_product_name,
customName: product.custom_name,
brand: product.brand,
categoryName: product.category_name,
productType: product.product_type,
customRateAmount: parseFloat(product.custom_rate_amount),
customRateUnit: product.custom_rate_unit,
notes: product.notes,
isShared: false,
createdAt: product.created_at
}))
}
});
} catch (error) {
next(error);
}
});
// @route GET /api/products/:id
// @desc Get single shared product with rates
// @access Private
router.get('/:id', validateParams(idParamSchema), async (req, res, next) => {
try {
const productId = req.params.id;
const productResult = await pool.query(
`SELECT p.*, pc.name as category_name
FROM products p
JOIN product_categories pc ON p.category_id = pc.id
WHERE p.id = $1`,
[productId]
);
if (productResult.rows.length === 0) {
throw new AppError('Product not found', 404);
}
const product = productResult.rows[0];
// Get product rates
const ratesResult = await pool.query(
'SELECT * FROM product_rates WHERE product_id = $1 ORDER BY application_type',
[productId]
);
res.json({
success: true,
data: {
product: {
id: product.id,
name: product.name,
brand: product.brand,
categoryName: product.category_name,
productType: product.product_type,
activeIngredients: product.active_ingredients,
description: product.description,
rates: ratesResult.rows.map(rate => ({
id: rate.id,
applicationType: rate.application_type,
rateAmount: parseFloat(rate.rate_amount),
rateUnit: rate.rate_unit,
notes: rate.notes,
createdAt: rate.created_at
})),
createdAt: product.created_at
}
}
});
} catch (error) {
next(error);
}
});
// @route POST /api/products
// @desc Create new shared product (admin only)
// @access Private (Admin)
router.post('/', validateRequest(productSchema), async (req, res, next) => {
try {
// Check if user is admin
if (req.user.role !== 'admin') {
throw new AppError('Admin access required', 403);
}
const { name, brand, categoryId, productType, activeIngredients, description } = req.body;
// Check if category exists
const categoryCheck = await pool.query(
'SELECT id FROM product_categories WHERE id = $1',
[categoryId]
);
if (categoryCheck.rows.length === 0) {
throw new AppError('Product category not found', 404);
}
const result = await pool.query(
`INSERT INTO products (name, brand, category_id, product_type, active_ingredients, description)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[name, brand, categoryId, productType, activeIngredients, description]
);
const product = result.rows[0];
res.status(201).json({
success: true,
message: 'Product created successfully',
data: {
product: {
id: product.id,
name: product.name,
brand: product.brand,
categoryId: product.category_id,
productType: product.product_type,
activeIngredients: product.active_ingredients,
description: product.description,
createdAt: product.created_at
}
}
});
} catch (error) {
next(error);
}
});
// @route POST /api/products/:id/rates
// @desc Add application rate to product (admin only)
// @access Private (Admin)
router.post('/:id/rates', validateParams(idParamSchema), validateRequest(productRateSchema), async (req, res, next) => {
try {
// Check if user is admin
if (req.user.role !== 'admin') {
throw new AppError('Admin access required', 403);
}
const productId = req.params.id;
const { applicationType, rateAmount, rateUnit, notes } = req.body;
// Check if product exists
const productCheck = await pool.query(
'SELECT id FROM products WHERE id = $1',
[productId]
);
if (productCheck.rows.length === 0) {
throw new AppError('Product not found', 404);
}
const result = await pool.query(
`INSERT INTO product_rates (product_id, application_type, rate_amount, rate_unit, notes)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[productId, applicationType, rateAmount, rateUnit, notes]
);
const rate = result.rows[0];
res.status(201).json({
success: true,
message: 'Application rate added successfully',
data: {
rate: {
id: rate.id,
productId: rate.product_id,
applicationType: rate.application_type,
rateAmount: parseFloat(rate.rate_amount),
rateUnit: rate.rate_unit,
notes: rate.notes,
createdAt: rate.created_at
}
}
});
} catch (error) {
next(error);
}
});
// @route POST /api/products/user
// @desc Create user's custom product
// @access Private
router.post('/user', validateRequest(userProductSchema), async (req, res, next) => {
try {
const { productId, customName, customRateAmount, customRateUnit, notes } = req.body;
// If based on existing product, verify it exists
if (productId) {
const productCheck = await pool.query(
'SELECT id FROM products WHERE id = $1',
[productId]
);
if (productCheck.rows.length === 0) {
throw new AppError('Base product not found', 404);
}
}
// Require either productId or customName
if (!productId && !customName) {
throw new AppError('Either base product or custom name is required', 400);
}
const result = await pool.query(
`INSERT INTO user_products (user_id, product_id, custom_name, custom_rate_amount, custom_rate_unit, notes)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[req.user.id, productId, customName, customRateAmount, customRateUnit, notes]
);
const userProduct = result.rows[0];
res.status(201).json({
success: true,
message: 'Custom product created successfully',
data: {
userProduct: {
id: userProduct.id,
baseProductId: userProduct.product_id,
customName: userProduct.custom_name,
customRateAmount: parseFloat(userProduct.custom_rate_amount),
customRateUnit: userProduct.custom_rate_unit,
notes: userProduct.notes,
createdAt: userProduct.created_at
}
}
});
} catch (error) {
next(error);
}
});
// @route GET /api/products/user/:id
// @desc Get user's custom product
// @access Private
router.get('/user/:id', validateParams(idParamSchema), async (req, res, next) => {
try {
const userProductId = req.params.id;
const result = await pool.query(
`SELECT up.*, p.name as base_product_name, p.brand, p.product_type,
p.active_ingredients, pc.name as category_name
FROM user_products up
LEFT JOIN products p ON up.product_id = p.id
LEFT JOIN product_categories pc ON p.category_id = pc.id
WHERE up.id = $1 AND up.user_id = $2`,
[userProductId, req.user.id]
);
if (result.rows.length === 0) {
throw new AppError('User product not found', 404);
}
const userProduct = result.rows[0];
res.json({
success: true,
data: {
userProduct: {
id: userProduct.id,
baseProductId: userProduct.product_id,
baseProductName: userProduct.base_product_name,
customName: userProduct.custom_name,
brand: userProduct.brand,
categoryName: userProduct.category_name,
productType: userProduct.product_type,
activeIngredients: userProduct.active_ingredients,
customRateAmount: parseFloat(userProduct.custom_rate_amount),
customRateUnit: userProduct.custom_rate_unit,
notes: userProduct.notes,
createdAt: userProduct.created_at
}
}
});
} catch (error) {
next(error);
}
});
// @route PUT /api/products/user/:id
// @desc Update user's custom product
// @access Private
router.put('/user/:id', validateParams(idParamSchema), validateRequest(userProductSchema), async (req, res, next) => {
try {
const userProductId = req.params.id;
const { productId, customName, customRateAmount, customRateUnit, notes } = req.body;
// Check if user product exists and belongs to user
const checkResult = await pool.query(
'SELECT id FROM user_products WHERE id = $1 AND user_id = $2',
[userProductId, req.user.id]
);
if (checkResult.rows.length === 0) {
throw new AppError('User product not found', 404);
}
// If changing base product, verify it exists
if (productId) {
const productCheck = await pool.query(
'SELECT id FROM products WHERE id = $1',
[productId]
);
if (productCheck.rows.length === 0) {
throw new AppError('Base product not found', 404);
}
}
const result = await pool.query(
`UPDATE user_products
SET product_id = $1, custom_name = $2, custom_rate_amount = $3,
custom_rate_unit = $4, notes = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $6
RETURNING *`,
[productId, customName, customRateAmount, customRateUnit, notes, userProductId]
);
const userProduct = result.rows[0];
res.json({
success: true,
message: 'Custom product updated successfully',
data: {
userProduct: {
id: userProduct.id,
baseProductId: userProduct.product_id,
customName: userProduct.custom_name,
customRateAmount: parseFloat(userProduct.custom_rate_amount),
customRateUnit: userProduct.custom_rate_unit,
notes: userProduct.notes,
createdAt: userProduct.created_at,
updatedAt: userProduct.updated_at
}
}
});
} catch (error) {
next(error);
}
});
// @route DELETE /api/products/user/:id
// @desc Delete user's custom product
// @access Private
router.delete('/user/:id', validateParams(idParamSchema), async (req, res, next) => {
try {
const userProductId = req.params.id;
// Check if user product exists and belongs to user
const checkResult = await pool.query(
'SELECT id FROM user_products WHERE id = $1 AND user_id = $2',
[userProductId, req.user.id]
);
if (checkResult.rows.length === 0) {
throw new AppError('User product not found', 404);
}
// Check if product is used in any applications
const usageCheck = await pool.query(
`SELECT COUNT(*) as count
FROM application_plan_products
WHERE user_product_id = $1`,
[userProductId]
);
if (parseInt(usageCheck.rows[0].count) > 0) {
throw new AppError('Cannot delete product that has been used in applications', 400);
}
await pool.query('DELETE FROM user_products WHERE id = $1', [userProductId]);
res.json({
success: true,
message: 'Custom product deleted successfully'
});
} catch (error) {
next(error);
}
});
// @route GET /api/products/search
// @desc Search products by name or ingredients
// @access Private
router.get('/search', async (req, res, next) => {
try {
const { q, limit = 10 } = req.query;
if (!q || q.length < 2) {
throw new AppError('Search query must be at least 2 characters', 400);
}
const result = await pool.query(
`SELECT p.*, pc.name as category_name
FROM products p
JOIN product_categories pc ON p.category_id = pc.id
WHERE p.name ILIKE $1 OR p.brand ILIKE $1 OR p.active_ingredients ILIKE $1
ORDER BY
CASE
WHEN p.name ILIKE $2 THEN 1
WHEN p.brand ILIKE $2 THEN 2
ELSE 3
END,
p.name
LIMIT $3`,
[`%${q}%`, `${q}%`, limit]
);
res.json({
success: true,
data: {
products: result.rows.map(product => ({
id: product.id,
name: product.name,
brand: product.brand,
categoryName: product.category_name,
productType: product.product_type,
activeIngredients: product.active_ingredients
}))
}
});
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -0,0 +1,410 @@
const express = require('express');
const pool = require('../config/database');
const { validateRequest, validateParams } = require('../utils/validation');
const { propertySchema, lawnSectionSchema, idParamSchema } = require('../utils/validation');
const { AppError } = require('../middleware/errorHandler');
const router = express.Router();
// Helper function to calculate polygon area (in square feet)
const calculatePolygonArea = (coordinates) => {
if (!coordinates || coordinates.length < 3) return 0;
// Shoelace formula for polygon area
let area = 0;
const n = coordinates.length;
for (let i = 0; i < n; i++) {
const j = (i + 1) % n;
area += coordinates[i][0] * coordinates[j][1];
area -= coordinates[j][0] * coordinates[i][1];
}
area = Math.abs(area) / 2;
// Convert from decimal degrees to square feet (approximate)
// This is a rough approximation - in production you'd use proper geodesic calculations
const avgLat = coordinates.reduce((sum, coord) => sum + coord[1], 0) / n;
const meterToFeet = 3.28084;
const degToMeter = 111320 * Math.cos(avgLat * Math.PI / 180);
return area * Math.pow(degToMeter * meterToFeet, 2);
};
// @route GET /api/properties
// @desc Get all properties for current user
// @access Private
router.get('/', async (req, res, next) => {
try {
const result = await pool.query(
`SELECT p.*,
COUNT(ls.id) as section_count,
COALESCE(SUM(ls.area), 0) as calculated_area
FROM properties p
LEFT JOIN lawn_sections ls ON p.id = ls.property_id
WHERE p.user_id = $1
GROUP BY p.id
ORDER BY p.created_at DESC`,
[req.user.id]
);
res.json({
success: true,
data: {
properties: result.rows.map(row => ({
id: row.id,
name: row.name,
address: row.address,
latitude: parseFloat(row.latitude),
longitude: parseFloat(row.longitude),
totalArea: parseFloat(row.total_area),
calculatedArea: parseFloat(row.calculated_area),
sectionCount: parseInt(row.section_count),
createdAt: row.created_at,
updatedAt: row.updated_at
}))
}
});
} catch (error) {
next(error);
}
});
// @route GET /api/properties/:id
// @desc Get single property with sections
// @access Private
router.get('/:id', validateParams(idParamSchema), async (req, res, next) => {
try {
const propertyId = req.params.id;
// Get property
const propertyResult = await pool.query(
'SELECT * FROM properties WHERE id = $1 AND user_id = $2',
[propertyId, req.user.id]
);
if (propertyResult.rows.length === 0) {
throw new AppError('Property not found', 404);
}
const property = propertyResult.rows[0];
// Get lawn sections
const sectionsResult = await pool.query(
'SELECT * FROM lawn_sections WHERE property_id = $1 ORDER BY name',
[propertyId]
);
res.json({
success: true,
data: {
property: {
id: property.id,
name: property.name,
address: property.address,
latitude: parseFloat(property.latitude),
longitude: parseFloat(property.longitude),
totalArea: parseFloat(property.total_area),
createdAt: property.created_at,
updatedAt: property.updated_at,
sections: sectionsResult.rows.map(section => ({
id: section.id,
name: section.name,
area: parseFloat(section.area),
polygonData: section.polygon_data,
grassType: section.grass_type,
soilType: section.soil_type,
createdAt: section.created_at,
updatedAt: section.updated_at
}))
}
}
});
} catch (error) {
next(error);
}
});
// @route POST /api/properties
// @desc Create new property
// @access Private
router.post('/', validateRequest(propertySchema), async (req, res, next) => {
try {
const { name, address, latitude, longitude, totalArea } = req.body;
const result = await pool.query(
`INSERT INTO properties (user_id, name, address, latitude, longitude, total_area)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[req.user.id, name, address, latitude, longitude, totalArea]
);
const property = result.rows[0];
res.status(201).json({
success: true,
message: 'Property created successfully',
data: {
property: {
id: property.id,
name: property.name,
address: property.address,
latitude: parseFloat(property.latitude),
longitude: parseFloat(property.longitude),
totalArea: parseFloat(property.total_area),
createdAt: property.created_at,
updatedAt: property.updated_at
}
}
});
} catch (error) {
next(error);
}
});
// @route PUT /api/properties/:id
// @desc Update property
// @access Private
router.put('/:id', validateParams(idParamSchema), validateRequest(propertySchema), async (req, res, next) => {
try {
const propertyId = req.params.id;
const { name, address, latitude, longitude, totalArea } = req.body;
// Check if property exists and belongs to user
const checkResult = await pool.query(
'SELECT id FROM properties WHERE id = $1 AND user_id = $2',
[propertyId, req.user.id]
);
if (checkResult.rows.length === 0) {
throw new AppError('Property not found', 404);
}
const result = await pool.query(
`UPDATE properties
SET name = $1, address = $2, latitude = $3, longitude = $4, total_area = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $6
RETURNING *`,
[name, address, latitude, longitude, totalArea, propertyId]
);
const property = result.rows[0];
res.json({
success: true,
message: 'Property updated successfully',
data: {
property: {
id: property.id,
name: property.name,
address: property.address,
latitude: parseFloat(property.latitude),
longitude: parseFloat(property.longitude),
totalArea: parseFloat(property.total_area),
createdAt: property.created_at,
updatedAt: property.updated_at
}
}
});
} catch (error) {
next(error);
}
});
// @route DELETE /api/properties/:id
// @desc Delete property
// @access Private
router.delete('/:id', validateParams(idParamSchema), async (req, res, next) => {
try {
const propertyId = req.params.id;
// Check if property exists and belongs to user
const checkResult = await pool.query(
'SELECT id FROM properties WHERE id = $1 AND user_id = $2',
[propertyId, req.user.id]
);
if (checkResult.rows.length === 0) {
throw new AppError('Property not found', 404);
}
// Check for active applications
const activeApps = await pool.query(
`SELECT COUNT(*) as count
FROM application_plans ap
JOIN lawn_sections ls ON ap.lawn_section_id = ls.id
WHERE ls.property_id = $1 AND ap.status IN ('planned', 'in_progress')`,
[propertyId]
);
if (parseInt(activeApps.rows[0].count) > 0) {
throw new AppError('Cannot delete property with active applications', 400);
}
await pool.query('DELETE FROM properties WHERE id = $1', [propertyId]);
res.json({
success: true,
message: 'Property deleted successfully'
});
} catch (error) {
next(error);
}
});
// @route POST /api/properties/:id/sections
// @desc Create lawn section for property
// @access Private
router.post('/:id/sections', validateParams(idParamSchema), validateRequest(lawnSectionSchema), async (req, res, next) => {
try {
const propertyId = req.params.id;
const { name, area, polygonData, grassType, soilType } = req.body;
// Check if property exists and belongs to user
const propertyResult = await pool.query(
'SELECT id FROM properties WHERE id = $1 AND user_id = $2',
[propertyId, req.user.id]
);
if (propertyResult.rows.length === 0) {
throw new AppError('Property not found', 404);
}
// Calculate area from polygon if provided
let calculatedArea = area;
if (polygonData && polygonData.coordinates && polygonData.coordinates[0]) {
calculatedArea = calculatePolygonArea(polygonData.coordinates[0]);
}
const result = await pool.query(
`INSERT INTO lawn_sections (property_id, name, area, polygon_data, grass_type, soil_type)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *`,
[propertyId, name, calculatedArea, JSON.stringify(polygonData), grassType, soilType]
);
const section = result.rows[0];
res.status(201).json({
success: true,
message: 'Lawn section created successfully',
data: {
section: {
id: section.id,
name: section.name,
area: parseFloat(section.area),
polygonData: section.polygon_data,
grassType: section.grass_type,
soilType: section.soil_type,
createdAt: section.created_at,
updatedAt: section.updated_at
}
}
});
} catch (error) {
next(error);
}
});
// @route PUT /api/properties/:propertyId/sections/:sectionId
// @desc Update lawn section
// @access Private
router.put('/:propertyId/sections/:sectionId', async (req, res, next) => {
try {
const { propertyId, sectionId } = req.params;
const { name, area, polygonData, grassType, soilType } = req.body;
// Check if section exists and user owns the property
const checkResult = await pool.query(
`SELECT ls.id
FROM lawn_sections ls
JOIN properties p ON ls.property_id = p.id
WHERE ls.id = $1 AND p.id = $2 AND p.user_id = $3`,
[sectionId, propertyId, req.user.id]
);
if (checkResult.rows.length === 0) {
throw new AppError('Lawn section not found', 404);
}
// Calculate area from polygon if provided
let calculatedArea = area;
if (polygonData && polygonData.coordinates && polygonData.coordinates[0]) {
calculatedArea = calculatePolygonArea(polygonData.coordinates[0]);
}
const result = await pool.query(
`UPDATE lawn_sections
SET name = $1, area = $2, polygon_data = $3, grass_type = $4, soil_type = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $6
RETURNING *`,
[name, calculatedArea, JSON.stringify(polygonData), grassType, soilType, sectionId]
);
const section = result.rows[0];
res.json({
success: true,
message: 'Lawn section updated successfully',
data: {
section: {
id: section.id,
name: section.name,
area: parseFloat(section.area),
polygonData: section.polygon_data,
grassType: section.grass_type,
soilType: section.soil_type,
createdAt: section.created_at,
updatedAt: section.updated_at
}
}
});
} catch (error) {
next(error);
}
});
// @route DELETE /api/properties/:propertyId/sections/:sectionId
// @desc Delete lawn section
// @access Private
router.delete('/:propertyId/sections/:sectionId', async (req, res, next) => {
try {
const { propertyId, sectionId } = req.params;
// Check if section exists and user owns the property
const checkResult = await pool.query(
`SELECT ls.id
FROM lawn_sections ls
JOIN properties p ON ls.property_id = p.id
WHERE ls.id = $1 AND p.id = $2 AND p.user_id = $3`,
[sectionId, propertyId, req.user.id]
);
if (checkResult.rows.length === 0) {
throw new AppError('Lawn section not found', 404);
}
// Check for active applications
const activeApps = await pool.query(
`SELECT COUNT(*) as count
FROM application_plans
WHERE lawn_section_id = $1 AND status IN ('planned', 'in_progress')`,
[sectionId]
);
if (parseInt(activeApps.rows[0].count) > 0) {
throw new AppError('Cannot delete section with active applications', 400);
}
await pool.query('DELETE FROM lawn_sections WHERE id = $1', [sectionId]);
res.json({
success: true,
message: 'Lawn section deleted successfully'
});
} catch (error) {
next(error);
}
});
module.exports = router;

225
backend/src/routes/users.js Normal file
View File

@@ -0,0 +1,225 @@
const express = require('express');
const pool = require('../config/database');
const { validateRequest, validateParams } = require('../utils/validation');
const { updateUserSchema, idParamSchema } = require('../utils/validation');
const { requireOwnership } = require('../middleware/auth');
const { AppError } = require('../middleware/errorHandler');
const router = express.Router();
// @route GET /api/users/profile
// @desc Get current user profile
// @access Private
router.get('/profile', async (req, res, next) => {
try {
const userResult = await pool.query(
`SELECT u.id, u.email, u.first_name, u.last_name, u.role, u.created_at, u.updated_at,
COUNT(p.id) as property_count
FROM users u
LEFT JOIN properties p ON u.id = p.user_id
WHERE u.id = $1
GROUP BY u.id`,
[req.user.id]
);
if (userResult.rows.length === 0) {
throw new AppError('User not found', 404);
}
const user = userResult.rows[0];
res.json({
success: true,
data: {
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role,
propertyCount: parseInt(user.property_count),
createdAt: user.created_at,
updatedAt: user.updated_at
}
}
});
} catch (error) {
next(error);
}
});
// @route PUT /api/users/profile
// @desc Update current user profile
// @access Private
router.put('/profile', validateRequest(updateUserSchema), async (req, res, next) => {
try {
const { firstName, lastName, email } = req.body;
const userId = req.user.id;
// Check if email is already taken by another user
if (email) {
const emailCheck = await pool.query(
'SELECT id FROM users WHERE email = $1 AND id != $2',
[email.toLowerCase(), userId]
);
if (emailCheck.rows.length > 0) {
throw new AppError('Email is already taken by another user', 409);
}
}
// Build dynamic update query
const updates = [];
const values = [];
let paramCount = 1;
if (firstName) {
updates.push(`first_name = $${paramCount}`);
values.push(firstName);
paramCount++;
}
if (lastName) {
updates.push(`last_name = $${paramCount}`);
values.push(lastName);
paramCount++;
}
if (email) {
updates.push(`email = $${paramCount}`);
values.push(email.toLowerCase());
paramCount++;
}
if (updates.length === 0) {
throw new AppError('No valid fields to update', 400);
}
updates.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(userId);
const query = `
UPDATE users
SET ${updates.join(', ')}
WHERE id = $${paramCount}
RETURNING id, email, first_name, last_name, role, updated_at
`;
const result = await pool.query(query, values);
const user = result.rows[0];
res.json({
success: true,
message: 'Profile updated successfully',
data: {
user: {
id: user.id,
email: user.email,
firstName: user.first_name,
lastName: user.last_name,
role: user.role,
updatedAt: user.updated_at
}
}
});
} catch (error) {
next(error);
}
});
// @route DELETE /api/users/account
// @desc Delete current user account
// @access Private
router.delete('/account', async (req, res, next) => {
try {
const userId = req.user.id;
// Check if user has any ongoing applications
const ongoingApps = await pool.query(
`SELECT COUNT(*) as count
FROM application_plans
WHERE user_id = $1 AND status IN ('planned', 'in_progress')`,
[userId]
);
if (parseInt(ongoingApps.rows[0].count) > 0) {
throw new AppError('Cannot delete account with ongoing applications. Please complete or cancel them first.', 400);
}
// Delete user (cascading will handle related records)
await pool.query('DELETE FROM users WHERE id = $1', [userId]);
res.json({
success: true,
message: 'Account deleted successfully'
});
} catch (error) {
next(error);
}
});
// @route GET /api/users/stats
// @desc Get user statistics
// @access Private
router.get('/stats', async (req, res, next) => {
try {
const userId = req.user.id;
const statsQuery = `
SELECT
(SELECT COUNT(*) FROM properties WHERE user_id = $1) as total_properties,
(SELECT COUNT(*) FROM lawn_sections ls
JOIN properties p ON ls.property_id = p.id
WHERE p.user_id = $1) as total_sections,
(SELECT COALESCE(SUM(ls.area), 0) FROM lawn_sections ls
JOIN properties p ON ls.property_id = p.id
WHERE p.user_id = $1) as total_area,
(SELECT COUNT(*) FROM user_equipment WHERE user_id = $1) as total_equipment,
(SELECT COUNT(*) FROM application_plans WHERE user_id = $1) as total_plans,
(SELECT COUNT(*) FROM application_logs WHERE user_id = $1) as total_applications,
(SELECT COUNT(*) FROM application_plans
WHERE user_id = $1 AND status = 'completed') as completed_plans,
(SELECT COUNT(*) FROM application_plans
WHERE user_id = $1 AND planned_date >= CURRENT_DATE) as upcoming_plans
`;
const result = await pool.query(statsQuery, [userId]);
const stats = result.rows[0];
// Get recent activity
const recentActivity = await pool.query(
`SELECT 'application' as type, al.application_date as date,
ls.name as section_name, p.name as property_name
FROM application_logs al
JOIN lawn_sections ls ON al.lawn_section_id = ls.id
JOIN properties p ON ls.property_id = p.id
WHERE al.user_id = $1
ORDER BY al.application_date DESC
LIMIT 5`,
[userId]
);
res.json({
success: true,
data: {
stats: {
totalProperties: parseInt(stats.total_properties),
totalSections: parseInt(stats.total_sections),
totalArea: parseFloat(stats.total_area),
totalEquipment: parseInt(stats.total_equipment),
totalPlans: parseInt(stats.total_plans),
totalApplications: parseInt(stats.total_applications),
completedPlans: parseInt(stats.completed_plans),
upcomingPlans: parseInt(stats.upcoming_plans),
completionRate: stats.total_plans > 0 ?
Math.round((stats.completed_plans / stats.total_plans) * 100) : 0
},
recentActivity: recentActivity.rows
}
});
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -0,0 +1,454 @@
const express = require('express');
const axios = require('axios');
const pool = require('../config/database');
const { AppError } = require('../middleware/errorHandler');
const router = express.Router();
// @route GET /api/weather/:propertyId
// @desc Get current weather for property location
// @access Private
router.get('/:propertyId', async (req, res, next) => {
try {
const propertyId = req.params.propertyId;
// Verify property belongs to user
const propertyResult = await pool.query(
'SELECT id, name, latitude, longitude FROM properties WHERE id = $1 AND user_id = $2',
[propertyId, req.user.id]
);
if (propertyResult.rows.length === 0) {
throw new AppError('Property not found', 404);
}
const property = propertyResult.rows[0];
if (!property.latitude || !property.longitude) {
throw new AppError('Property location coordinates not set', 400);
}
const apiKey = process.env.WEATHER_API_KEY;
if (!apiKey) {
throw new AppError('Weather service not configured', 503);
}
// Fetch current weather from OpenWeatherMap
const weatherResponse = await axios.get(
`https://api.openweathermap.org/data/2.5/weather`,
{
params: {
lat: property.latitude,
lon: property.longitude,
appid: apiKey,
units: 'imperial'
},
timeout: 5000
}
);
const weatherData = weatherResponse.data;
// Store weather data in cache
const today = new Date().toISOString().split('T')[0];
await pool.query(
`INSERT INTO weather_data
(property_id, date, temperature_high, temperature_low, humidity, wind_speed, precipitation, conditions)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (property_id, date)
DO UPDATE SET
temperature_high = EXCLUDED.temperature_high,
temperature_low = EXCLUDED.temperature_low,
humidity = EXCLUDED.humidity,
wind_speed = EXCLUDED.wind_speed,
precipitation = EXCLUDED.precipitation,
conditions = EXCLUDED.conditions,
created_at = CURRENT_TIMESTAMP`,
[
propertyId,
today,
Math.round(weatherData.main.temp),
Math.round(weatherData.main.temp_min),
weatherData.main.humidity,
weatherData.wind?.speed || 0,
weatherData.rain?.['1h'] || 0,
weatherData.weather[0].description
]
);
res.json({
success: true,
data: {
weather: {
location: {
propertyId: property.id,
propertyName: property.name,
latitude: parseFloat(property.latitude),
longitude: parseFloat(property.longitude)
},
current: {
temperature: Math.round(weatherData.main.temp),
feelsLike: Math.round(weatherData.main.feels_like),
humidity: weatherData.main.humidity,
pressure: weatherData.main.pressure,
windSpeed: weatherData.wind?.speed || 0,
windDirection: weatherData.wind?.deg || 0,
visibility: weatherData.visibility ? weatherData.visibility / 1609.34 : null, // Convert to miles
uvIndex: weatherData.uvi || null,
conditions: weatherData.weather[0].description,
icon: weatherData.weather[0].icon
},
precipitation: {
current: weatherData.rain?.['1h'] || 0,
forecast3h: weatherData.rain?.['3h'] || 0
},
timestamps: {
sunrise: new Date(weatherData.sys.sunrise * 1000),
sunset: new Date(weatherData.sys.sunset * 1000),
lastUpdated: new Date()
}
}
}
});
} catch (error) {
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
throw new AppError('Weather service temporarily unavailable', 503);
}
if (error.response?.status === 401) {
throw new AppError('Weather service authentication failed', 503);
}
next(error);
}
});
// @route GET /api/weather/:propertyId/forecast
// @desc Get 5-day weather forecast for property
// @access Private
router.get('/:propertyId/forecast', async (req, res, next) => {
try {
const propertyId = req.params.propertyId;
// Verify property belongs to user
const propertyResult = await pool.query(
'SELECT id, name, latitude, longitude FROM properties WHERE id = $1 AND user_id = $2',
[propertyId, req.user.id]
);
if (propertyResult.rows.length === 0) {
throw new AppError('Property not found', 404);
}
const property = propertyResult.rows[0];
if (!property.latitude || !property.longitude) {
throw new AppError('Property location coordinates not set', 400);
}
const apiKey = process.env.WEATHER_API_KEY;
if (!apiKey) {
throw new AppError('Weather service not configured', 503);
}
// Fetch 5-day forecast from OpenWeatherMap
const forecastResponse = await axios.get(
`https://api.openweathermap.org/data/2.5/forecast`,
{
params: {
lat: property.latitude,
lon: property.longitude,
appid: apiKey,
units: 'imperial'
},
timeout: 5000
}
);
const forecastData = forecastResponse.data;
// Group forecast by day
const dailyForecast = {};
forecastData.list.forEach(item => {
const date = item.dt_txt.split(' ')[0];
if (!dailyForecast[date]) {
dailyForecast[date] = {
date,
temperatures: [],
humidity: [],
windSpeed: [],
precipitation: 0,
conditions: [],
timestamps: []
};
}
dailyForecast[date].temperatures.push(item.main.temp);
dailyForecast[date].humidity.push(item.main.humidity);
dailyForecast[date].windSpeed.push(item.wind?.speed || 0);
dailyForecast[date].precipitation += item.rain?.['3h'] || 0;
dailyForecast[date].conditions.push(item.weather[0].description);
dailyForecast[date].timestamps.push(new Date(item.dt * 1000));
});
// Process daily summaries
const forecast = Object.values(dailyForecast).map(day => {
const temps = day.temperatures;
const humidity = day.humidity;
const windSpeeds = day.windSpeed;
return {
date: day.date,
temperatureHigh: Math.round(Math.max(...temps)),
temperatureLow: Math.round(Math.min(...temps)),
averageHumidity: Math.round(humidity.reduce((a, b) => a + b, 0) / humidity.length),
maxWindSpeed: Math.round(Math.max(...windSpeeds)),
totalPrecipitation: Math.round(day.precipitation * 100) / 100,
conditions: day.conditions[0], // Use first condition of the day
timestamps: day.timestamps
};
}).slice(0, 5); // Limit to 5 days
res.json({
success: true,
data: {
forecast: {
location: {
propertyId: property.id,
propertyName: property.name,
latitude: parseFloat(property.latitude),
longitude: parseFloat(property.longitude)
},
daily: forecast,
lastUpdated: new Date()
}
}
});
} catch (error) {
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
throw new AppError('Weather service temporarily unavailable', 503);
}
if (error.response?.status === 401) {
throw new AppError('Weather service authentication failed', 503);
}
next(error);
}
});
// @route GET /api/weather/:propertyId/history
// @desc Get weather history for property
// @access Private
router.get('/:propertyId/history', async (req, res, next) => {
try {
const propertyId = req.params.propertyId;
const { start_date, end_date, limit = 30 } = req.query;
// Verify property belongs to user
const propertyResult = await pool.query(
'SELECT id, name FROM properties WHERE id = $1 AND user_id = $2',
[propertyId, req.user.id]
);
if (propertyResult.rows.length === 0) {
throw new AppError('Property not found', 404);
}
const property = propertyResult.rows[0];
let whereConditions = ['property_id = $1'];
let queryParams = [propertyId];
let paramCount = 1;
if (start_date) {
paramCount++;
whereConditions.push(`date >= $${paramCount}`);
queryParams.push(start_date);
}
if (end_date) {
paramCount++;
whereConditions.push(`date <= $${paramCount}`);
queryParams.push(end_date);
}
const whereClause = whereConditions.join(' AND ');
paramCount++;
queryParams.push(limit);
const result = await pool.query(
`SELECT * FROM weather_data
WHERE ${whereClause}
ORDER BY date DESC
LIMIT $${paramCount}`,
queryParams
);
res.json({
success: true,
data: {
history: {
location: {
propertyId: property.id,
propertyName: property.name
},
records: result.rows.map(record => ({
date: record.date,
temperatureHigh: record.temperature_high,
temperatureLow: record.temperature_low,
humidity: record.humidity,
windSpeed: parseFloat(record.wind_speed),
precipitation: parseFloat(record.precipitation),
conditions: record.conditions,
recordedAt: record.created_at
}))
}
}
});
} catch (error) {
next(error);
}
});
// @route GET /api/weather/conditions/suitable
// @desc Check if weather conditions are suitable for application
// @access Private
router.get('/conditions/suitable/:propertyId', async (req, res, next) => {
try {
const propertyId = req.params.propertyId;
const { application_type = 'general' } = req.query;
// Verify property belongs to user
const propertyResult = await pool.query(
'SELECT id, name, latitude, longitude FROM properties WHERE id = $1 AND user_id = $2',
[propertyId, req.user.id]
);
if (propertyResult.rows.length === 0) {
throw new AppError('Property not found', 404);
}
const property = propertyResult.rows[0];
if (!property.latitude || !property.longitude) {
throw new AppError('Property location coordinates not set', 400);
}
const apiKey = process.env.WEATHER_API_KEY;
if (!apiKey) {
throw new AppError('Weather service not configured', 503);
}
// Get current weather
const weatherResponse = await axios.get(
`https://api.openweathermap.org/data/2.5/weather`,
{
params: {
lat: property.latitude,
lon: property.longitude,
appid: apiKey,
units: 'imperial'
},
timeout: 5000
}
);
const weather = weatherResponse.data;
// Define suitability criteria based on application type
const criteria = {
general: {
maxWindSpeed: 10, // mph
maxTemperature: 85, // °F
minTemperature: 45, // °F
maxHumidity: 85, // %
maxPrecipitation: 0.1 // inches
},
herbicide: {
maxWindSpeed: 7,
maxTemperature: 80,
minTemperature: 50,
maxHumidity: 80,
maxPrecipitation: 0
},
fertilizer: {
maxWindSpeed: 15,
maxTemperature: 90,
minTemperature: 40,
maxHumidity: 90,
maxPrecipitation: 0
}
};
const rules = criteria[application_type] || criteria.general;
// Check conditions
const windSpeed = weather.wind?.speed || 0;
const temperature = weather.main.temp;
const humidity = weather.main.humidity;
const precipitation = weather.rain?.['1h'] || 0;
const checks = {
windSpeed: {
suitable: windSpeed <= rules.maxWindSpeed,
value: windSpeed,
threshold: rules.maxWindSpeed,
message: windSpeed > rules.maxWindSpeed ?
`Wind speed too high (${windSpeed} mph > ${rules.maxWindSpeed} mph)` : 'Wind speed acceptable'
},
temperature: {
suitable: temperature >= rules.minTemperature && temperature <= rules.maxTemperature,
value: temperature,
range: [rules.minTemperature, rules.maxTemperature],
message: temperature < rules.minTemperature ?
`Temperature too low (${Math.round(temperature)}°F < ${rules.minTemperature}°F)` :
temperature > rules.maxTemperature ?
`Temperature too high (${Math.round(temperature)}°F > ${rules.maxTemperature}°F)` :
'Temperature acceptable'
},
humidity: {
suitable: humidity <= rules.maxHumidity,
value: humidity,
threshold: rules.maxHumidity,
message: humidity > rules.maxHumidity ?
`Humidity too high (${humidity}% > ${rules.maxHumidity}%)` : 'Humidity acceptable'
},
precipitation: {
suitable: precipitation <= rules.maxPrecipitation,
value: precipitation,
threshold: rules.maxPrecipitation,
message: precipitation > rules.maxPrecipitation ?
`Active precipitation (${precipitation} in/hr)` : 'No precipitation'
}
};
const overallSuitable = Object.values(checks).every(check => check.suitable);
res.json({
success: true,
data: {
suitability: {
overall: overallSuitable,
applicationType: application_type,
checks,
recommendations: overallSuitable ?
['Conditions are suitable for application'] :
Object.values(checks)
.filter(check => !check.suitable)
.map(check => check.message),
lastUpdated: new Date()
}
}
});
} catch (error) {
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
throw new AppError('Weather service temporarily unavailable', 503);
}
if (error.response?.status === 401) {
throw new AppError('Weather service authentication failed', 503);
}
next(error);
}
});
module.exports = router;

View File

@@ -0,0 +1,164 @@
const Joi = require('joi');
// User validation schemas
const registerSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().min(8).pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/).required()
.messages({
'string.pattern.base': 'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character'
}),
firstName: Joi.string().max(100).required(),
lastName: Joi.string().max(100).required()
});
const loginSchema = Joi.object({
email: Joi.string().email().required(),
password: Joi.string().required()
});
const updateUserSchema = Joi.object({
firstName: Joi.string().max(100),
lastName: Joi.string().max(100),
email: Joi.string().email()
});
const changePasswordSchema = Joi.object({
currentPassword: Joi.string().required(),
newPassword: Joi.string().min(8).pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/).required()
.messages({
'string.pattern.base': 'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character'
})
});
// Property validation schemas
const propertySchema = Joi.object({
name: Joi.string().max(255).required(),
address: Joi.string().max(500),
latitude: Joi.number().min(-90).max(90),
longitude: Joi.number().min(-180).max(180),
totalArea: Joi.number().positive()
});
const lawnSectionSchema = Joi.object({
name: Joi.string().max(255).required(),
area: Joi.number().positive().required(),
polygonData: Joi.object(),
grassType: Joi.string().max(100),
soilType: Joi.string().max(100)
});
// Equipment validation schemas
const equipmentSchema = Joi.object({
equipmentTypeId: Joi.number().integer().positive().required(),
customName: Joi.string().max(255),
tankSize: Joi.number().positive(),
pumpGpm: Joi.number().positive(),
nozzleGpm: Joi.number().positive(),
nozzleCount: Joi.number().integer().positive(),
spreaderWidth: Joi.number().positive()
});
// Product validation schemas
const productSchema = Joi.object({
name: Joi.string().max(255).required(),
brand: Joi.string().max(100),
categoryId: Joi.number().integer().positive().required(),
productType: Joi.string().valid('granular', 'liquid').required(),
activeIngredients: Joi.string(),
description: Joi.string()
});
const productRateSchema = Joi.object({
applicationType: Joi.string().max(100).required(),
rateAmount: Joi.number().positive().required(),
rateUnit: Joi.string().max(50).required(),
notes: Joi.string()
});
const userProductSchema = Joi.object({
productId: Joi.number().integer().positive(),
customName: Joi.string().max(255),
customRateAmount: Joi.number().positive(),
customRateUnit: Joi.string().max(50),
notes: Joi.string()
});
// Application validation schemas
const applicationPlanSchema = Joi.object({
lawnSectionId: Joi.number().integer().positive().required(),
equipmentId: Joi.number().integer().positive().required(),
plannedDate: Joi.date().required(),
notes: Joi.string(),
products: Joi.array().items(Joi.object({
productId: Joi.number().integer().positive(),
userProductId: Joi.number().integer().positive(),
rateAmount: Joi.number().positive().required(),
rateUnit: Joi.string().max(50).required()
})).min(1).required()
});
const applicationLogSchema = Joi.object({
planId: Joi.number().integer().positive(),
lawnSectionId: Joi.number().integer().positive().required(),
equipmentId: Joi.number().integer().positive().required(),
weatherConditions: Joi.object(),
gpsTrack: Joi.object(),
averageSpeed: Joi.number().positive(),
areaCovered: Joi.number().positive(),
notes: Joi.string(),
products: Joi.array().items(Joi.object({
productId: Joi.number().integer().positive(),
userProductId: Joi.number().integer().positive(),
rateAmount: Joi.number().positive().required(),
rateUnit: Joi.string().max(50).required(),
actualProductAmount: Joi.number().positive(),
actualWaterAmount: Joi.number().positive(),
actualSpeedMph: Joi.number().positive()
})).min(1).required()
});
// Validation middleware
const validateRequest = (schema) => {
return (req, res, next) => {
const { error } = schema.validate(req.body, { abortEarly: false });
if (error) {
return next(error);
}
next();
};
};
const validateParams = (schema) => {
return (req, res, next) => {
const { error } = schema.validate(req.params, { abortEarly: false });
if (error) {
return next(error);
}
next();
};
};
const idParamSchema = Joi.object({
id: Joi.number().integer().positive().required()
});
module.exports = {
// Schemas
registerSchema,
loginSchema,
updateUserSchema,
changePasswordSchema,
propertySchema,
lawnSectionSchema,
equipmentSchema,
productSchema,
productRateSchema,
userProductSchema,
applicationPlanSchema,
applicationLogSchema,
idParamSchema,
// Middleware
validateRequest,
validateParams
};