Initial Claude Run
This commit is contained in:
31
backend/Dockerfile
Normal file
31
backend/Dockerfile
Normal 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
28
backend/healthcheck.js
Normal 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
43
backend/package.json
Normal 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
115
backend/src/app.js
Normal 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'}`);
|
||||
});
|
||||
38
backend/src/config/database.js
Normal file
38
backend/src/config/database.js
Normal 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;
|
||||
75
backend/src/middleware/auth.js
Normal file
75
backend/src/middleware/auth.js
Normal 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
|
||||
};
|
||||
97
backend/src/middleware/errorHandler.js
Normal file
97
backend/src/middleware/errorHandler.js
Normal 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
529
backend/src/routes/admin.js
Normal 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;
|
||||
590
backend/src/routes/applications.js
Normal file
590
backend/src/routes/applications.js
Normal file
@@ -0,0 +1,590 @@
|
||||
const express = require('express');
|
||||
const pool = require('../config/database');
|
||||
const { validateRequest, validateParams } = require('../utils/validation');
|
||||
const { applicationPlanSchema, applicationLogSchema, idParamSchema } = require('../utils/validation');
|
||||
const { AppError } = require('../middleware/errorHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// @route GET /api/applications/plans
|
||||
// @desc Get all application plans for current user
|
||||
// @access Private
|
||||
router.get('/plans', async (req, res, next) => {
|
||||
try {
|
||||
const { status, upcoming, property_id } = req.query;
|
||||
|
||||
let whereConditions = ['ap.user_id = $1'];
|
||||
let queryParams = [req.user.id];
|
||||
let paramCount = 1;
|
||||
|
||||
if (status) {
|
||||
paramCount++;
|
||||
whereConditions.push(`ap.status = $${paramCount}`);
|
||||
queryParams.push(status);
|
||||
}
|
||||
|
||||
if (upcoming === 'true') {
|
||||
paramCount++;
|
||||
whereConditions.push(`ap.planned_date >= $${paramCount}`);
|
||||
queryParams.push(new Date().toISOString().split('T')[0]);
|
||||
}
|
||||
|
||||
if (property_id) {
|
||||
paramCount++;
|
||||
whereConditions.push(`p.id = $${paramCount}`);
|
||||
queryParams.push(property_id);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(' AND ');
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT ap.*, ls.name as section_name, ls.area as section_area,
|
||||
p.name as property_name, p.address as property_address,
|
||||
ue.custom_name as equipment_name, et.name as equipment_type,
|
||||
COUNT(app.id) as product_count
|
||||
FROM application_plans ap
|
||||
JOIN lawn_sections ls ON ap.lawn_section_id = ls.id
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
LEFT JOIN user_equipment ue ON ap.equipment_id = ue.id
|
||||
LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id
|
||||
LEFT JOIN application_plan_products app ON ap.id = app.plan_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY ap.id, ls.name, ls.area, p.name, p.address, ue.custom_name, et.name
|
||||
ORDER BY ap.planned_date DESC, ap.created_at DESC`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
plans: result.rows.map(plan => ({
|
||||
id: plan.id,
|
||||
status: plan.status,
|
||||
plannedDate: plan.planned_date,
|
||||
notes: plan.notes,
|
||||
sectionName: plan.section_name,
|
||||
sectionArea: parseFloat(plan.section_area),
|
||||
propertyName: plan.property_name,
|
||||
propertyAddress: plan.property_address,
|
||||
equipmentName: plan.equipment_name || plan.equipment_type,
|
||||
productCount: parseInt(plan.product_count),
|
||||
createdAt: plan.created_at,
|
||||
updatedAt: plan.updated_at
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/applications/plans/:id
|
||||
// @desc Get single application plan with products
|
||||
// @access Private
|
||||
router.get('/plans/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const planId = req.params.id;
|
||||
|
||||
// Get plan details
|
||||
const planResult = await pool.query(
|
||||
`SELECT ap.*, ls.name as section_name, ls.area as section_area, ls.polygon_data,
|
||||
p.id as property_id, p.name as property_name, p.address as property_address,
|
||||
ue.id as equipment_id, ue.custom_name as equipment_name,
|
||||
et.name as equipment_type, et.category as equipment_category
|
||||
FROM application_plans ap
|
||||
JOIN lawn_sections ls ON ap.lawn_section_id = ls.id
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
LEFT JOIN user_equipment ue ON ap.equipment_id = ue.id
|
||||
LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id
|
||||
WHERE ap.id = $1 AND ap.user_id = $2`,
|
||||
[planId, req.user.id]
|
||||
);
|
||||
|
||||
if (planResult.rows.length === 0) {
|
||||
throw new AppError('Application plan not found', 404);
|
||||
}
|
||||
|
||||
const plan = planResult.rows[0];
|
||||
|
||||
// Get plan products
|
||||
const productsResult = await pool.query(
|
||||
`SELECT app.*,
|
||||
COALESCE(up.custom_name, p.name) as product_name,
|
||||
COALESCE(p.brand, '') as product_brand,
|
||||
COALESCE(p.product_type, 'unknown') as product_type
|
||||
FROM application_plan_products app
|
||||
LEFT JOIN products p ON app.product_id = p.id
|
||||
LEFT JOIN user_products up ON app.user_product_id = up.id
|
||||
WHERE app.plan_id = $1
|
||||
ORDER BY app.id`,
|
||||
[planId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
plan: {
|
||||
id: plan.id,
|
||||
status: plan.status,
|
||||
plannedDate: plan.planned_date,
|
||||
notes: plan.notes,
|
||||
section: {
|
||||
id: plan.lawn_section_id,
|
||||
name: plan.section_name,
|
||||
area: parseFloat(plan.section_area),
|
||||
polygonData: plan.polygon_data
|
||||
},
|
||||
property: {
|
||||
id: plan.property_id,
|
||||
name: plan.property_name,
|
||||
address: plan.property_address
|
||||
},
|
||||
equipment: {
|
||||
id: plan.equipment_id,
|
||||
name: plan.equipment_name || plan.equipment_type,
|
||||
type: plan.equipment_type,
|
||||
category: plan.equipment_category
|
||||
},
|
||||
products: productsResult.rows.map(product => ({
|
||||
id: product.id,
|
||||
productId: product.product_id,
|
||||
userProductId: product.user_product_id,
|
||||
productName: product.product_name,
|
||||
productBrand: product.product_brand,
|
||||
productType: product.product_type,
|
||||
rateAmount: parseFloat(product.rate_amount),
|
||||
rateUnit: product.rate_unit,
|
||||
calculatedProductAmount: parseFloat(product.calculated_product_amount),
|
||||
calculatedWaterAmount: parseFloat(product.calculated_water_amount),
|
||||
targetSpeedMph: parseFloat(product.target_speed_mph)
|
||||
})),
|
||||
createdAt: plan.created_at,
|
||||
updatedAt: plan.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/applications/plans
|
||||
// @desc Create new application plan
|
||||
// @access Private
|
||||
router.post('/plans', validateRequest(applicationPlanSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { lawnSectionId, equipmentId, plannedDate, notes, products } = req.body;
|
||||
|
||||
// Start transaction
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Verify lawn section belongs to user
|
||||
const sectionCheck = await client.query(
|
||||
`SELECT ls.id, ls.area, p.user_id
|
||||
FROM lawn_sections ls
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
WHERE ls.id = $1 AND p.user_id = $2`,
|
||||
[lawnSectionId, req.user.id]
|
||||
);
|
||||
|
||||
if (sectionCheck.rows.length === 0) {
|
||||
throw new AppError('Lawn section not found', 404);
|
||||
}
|
||||
|
||||
const section = sectionCheck.rows[0];
|
||||
|
||||
// Verify equipment belongs to user
|
||||
const equipmentCheck = await client.query(
|
||||
'SELECT id, tank_size, pump_gpm, nozzle_gpm, nozzle_count FROM user_equipment WHERE id = $1 AND user_id = $2',
|
||||
[equipmentId, req.user.id]
|
||||
);
|
||||
|
||||
if (equipmentCheck.rows.length === 0) {
|
||||
throw new AppError('Equipment not found', 404);
|
||||
}
|
||||
|
||||
const equipment = equipmentCheck.rows[0];
|
||||
|
||||
// Create application plan
|
||||
const planResult = await client.query(
|
||||
`INSERT INTO application_plans (user_id, lawn_section_id, equipment_id, planned_date, notes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[req.user.id, lawnSectionId, equipmentId, plannedDate, notes]
|
||||
);
|
||||
|
||||
const plan = planResult.rows[0];
|
||||
|
||||
// Add products to plan with calculations
|
||||
for (const product of products) {
|
||||
const { productId, userProductId, rateAmount, rateUnit } = product;
|
||||
|
||||
// Calculate application amounts based on area and rate
|
||||
const sectionArea = parseFloat(section.area);
|
||||
let calculatedProductAmount = 0;
|
||||
let calculatedWaterAmount = 0;
|
||||
let targetSpeed = 3; // Default 3 MPH
|
||||
|
||||
// Basic calculation logic (can be enhanced based on equipment type)
|
||||
if (rateUnit.includes('1000sqft')) {
|
||||
calculatedProductAmount = rateAmount * (sectionArea / 1000);
|
||||
} else if (rateUnit.includes('acre')) {
|
||||
calculatedProductAmount = rateAmount * (sectionArea / 43560);
|
||||
} else {
|
||||
calculatedProductAmount = rateAmount;
|
||||
}
|
||||
|
||||
// Water calculation for liquid applications
|
||||
if (rateUnit.includes('gal')) {
|
||||
calculatedWaterAmount = calculatedProductAmount;
|
||||
} else if (rateUnit.includes('oz/gal')) {
|
||||
calculatedWaterAmount = sectionArea / 1000; // 1 gal per 1000 sqft default
|
||||
calculatedProductAmount = rateAmount * calculatedWaterAmount;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO application_plan_products
|
||||
(plan_id, product_id, user_product_id, rate_amount, rate_unit,
|
||||
calculated_product_amount, calculated_water_amount, target_speed_mph)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[plan.id, productId, userProductId, rateAmount, rateUnit,
|
||||
calculatedProductAmount, calculatedWaterAmount, targetSpeed]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Application plan created successfully',
|
||||
data: {
|
||||
plan: {
|
||||
id: plan.id,
|
||||
status: plan.status,
|
||||
plannedDate: plan.planned_date,
|
||||
notes: plan.notes,
|
||||
createdAt: plan.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/applications/plans/:id/status
|
||||
// @desc Update application plan status
|
||||
// @access Private
|
||||
router.put('/plans/:id/status', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const planId = req.params.id;
|
||||
const { status } = req.body;
|
||||
|
||||
if (!['planned', 'in_progress', 'completed', 'cancelled'].includes(status)) {
|
||||
throw new AppError('Invalid status', 400);
|
||||
}
|
||||
|
||||
// Check if plan belongs to user
|
||||
const checkResult = await pool.query(
|
||||
'SELECT id, status FROM application_plans WHERE id = $1 AND user_id = $2',
|
||||
[planId, req.user.id]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length === 0) {
|
||||
throw new AppError('Application plan not found', 404);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE application_plans
|
||||
SET status = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
RETURNING *`,
|
||||
[status, planId]
|
||||
);
|
||||
|
||||
const plan = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Plan status updated successfully',
|
||||
data: {
|
||||
plan: {
|
||||
id: plan.id,
|
||||
status: plan.status,
|
||||
updatedAt: plan.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/applications/logs
|
||||
// @desc Get application logs for current user
|
||||
// @access Private
|
||||
router.get('/logs', async (req, res, next) => {
|
||||
try {
|
||||
const { property_id, start_date, end_date, limit = 50 } = req.query;
|
||||
|
||||
let whereConditions = ['al.user_id = $1'];
|
||||
let queryParams = [req.user.id];
|
||||
let paramCount = 1;
|
||||
|
||||
if (property_id) {
|
||||
paramCount++;
|
||||
whereConditions.push(`p.id = $${paramCount}`);
|
||||
queryParams.push(property_id);
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
paramCount++;
|
||||
whereConditions.push(`al.application_date >= $${paramCount}`);
|
||||
queryParams.push(start_date);
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
paramCount++;
|
||||
whereConditions.push(`al.application_date <= $${paramCount}`);
|
||||
queryParams.push(end_date);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(' AND ');
|
||||
|
||||
paramCount++;
|
||||
queryParams.push(limit);
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT al.*, ls.name as section_name, ls.area as section_area,
|
||||
p.name as property_name, p.address as property_address,
|
||||
ue.custom_name as equipment_name, et.name as equipment_type,
|
||||
COUNT(alp.id) as product_count
|
||||
FROM application_logs al
|
||||
JOIN lawn_sections ls ON al.lawn_section_id = ls.id
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
LEFT JOIN user_equipment ue ON al.equipment_id = ue.id
|
||||
LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id
|
||||
LEFT JOIN application_log_products alp ON al.id = alp.log_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY al.id, ls.name, ls.area, p.name, p.address, ue.custom_name, et.name
|
||||
ORDER BY al.application_date DESC
|
||||
LIMIT $${paramCount}`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
logs: result.rows.map(log => ({
|
||||
id: log.id,
|
||||
planId: log.plan_id,
|
||||
applicationDate: log.application_date,
|
||||
weatherConditions: log.weather_conditions,
|
||||
averageSpeed: parseFloat(log.average_speed),
|
||||
areaCovered: parseFloat(log.area_covered),
|
||||
notes: log.notes,
|
||||
sectionName: log.section_name,
|
||||
sectionArea: parseFloat(log.section_area),
|
||||
propertyName: log.property_name,
|
||||
propertyAddress: log.property_address,
|
||||
equipmentName: log.equipment_name || log.equipment_type,
|
||||
productCount: parseInt(log.product_count),
|
||||
createdAt: log.created_at
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/applications/logs
|
||||
// @desc Create application log
|
||||
// @access Private
|
||||
router.post('/logs', validateRequest(applicationLogSchema), async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
planId,
|
||||
lawnSectionId,
|
||||
equipmentId,
|
||||
weatherConditions,
|
||||
gpsTrack,
|
||||
averageSpeed,
|
||||
areaCovered,
|
||||
notes,
|
||||
products
|
||||
} = req.body;
|
||||
|
||||
// Start transaction
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Verify lawn section belongs to user
|
||||
const sectionCheck = await client.query(
|
||||
`SELECT ls.id, p.user_id
|
||||
FROM lawn_sections ls
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
WHERE ls.id = $1 AND p.user_id = $2`,
|
||||
[lawnSectionId, req.user.id]
|
||||
);
|
||||
|
||||
if (sectionCheck.rows.length === 0) {
|
||||
throw new AppError('Lawn section not found', 404);
|
||||
}
|
||||
|
||||
// Verify equipment belongs to user (if provided)
|
||||
if (equipmentId) {
|
||||
const equipmentCheck = await client.query(
|
||||
'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2',
|
||||
[equipmentId, req.user.id]
|
||||
);
|
||||
|
||||
if (equipmentCheck.rows.length === 0) {
|
||||
throw new AppError('Equipment not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
// Create application log
|
||||
const logResult = await client.query(
|
||||
`INSERT INTO application_logs
|
||||
(plan_id, user_id, lawn_section_id, equipment_id, weather_conditions,
|
||||
gps_track, average_speed, area_covered, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[planId, req.user.id, lawnSectionId, equipmentId,
|
||||
JSON.stringify(weatherConditions), JSON.stringify(gpsTrack),
|
||||
averageSpeed, areaCovered, notes]
|
||||
);
|
||||
|
||||
const log = logResult.rows[0];
|
||||
|
||||
// Add products to log
|
||||
for (const product of products) {
|
||||
const {
|
||||
productId,
|
||||
userProductId,
|
||||
rateAmount,
|
||||
rateUnit,
|
||||
actualProductAmount,
|
||||
actualWaterAmount,
|
||||
actualSpeedMph
|
||||
} = product;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO application_log_products
|
||||
(log_id, product_id, user_product_id, rate_amount, rate_unit,
|
||||
actual_product_amount, actual_water_amount, actual_speed_mph)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[log.id, productId, userProductId, rateAmount, rateUnit,
|
||||
actualProductAmount, actualWaterAmount, actualSpeedMph]
|
||||
);
|
||||
}
|
||||
|
||||
// If this was from a plan, mark the plan as completed
|
||||
if (planId) {
|
||||
await client.query(
|
||||
'UPDATE application_plans SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
['completed', planId]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Application logged successfully',
|
||||
data: {
|
||||
log: {
|
||||
id: log.id,
|
||||
applicationDate: log.application_date,
|
||||
createdAt: log.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/applications/stats
|
||||
// @desc Get application statistics
|
||||
// @access Private
|
||||
router.get('/stats', async (req, res, next) => {
|
||||
try {
|
||||
const { year = new Date().getFullYear() } = req.query;
|
||||
|
||||
const statsQuery = `
|
||||
SELECT
|
||||
COUNT(DISTINCT al.id) as total_applications,
|
||||
COUNT(DISTINCT ap.id) as total_plans,
|
||||
COUNT(DISTINCT CASE WHEN ap.status = 'completed' THEN ap.id END) as completed_plans,
|
||||
COUNT(DISTINCT CASE WHEN ap.status = 'planned' THEN ap.id END) as planned_applications,
|
||||
COALESCE(SUM(al.area_covered), 0) as total_area_treated,
|
||||
COALESCE(AVG(al.average_speed), 0) as avg_application_speed
|
||||
FROM application_logs al
|
||||
FULL OUTER JOIN application_plans ap ON al.plan_id = ap.id OR ap.user_id = $1
|
||||
WHERE EXTRACT(YEAR FROM COALESCE(al.application_date, ap.planned_date)) = $2
|
||||
AND (al.user_id = $1 OR ap.user_id = $1)
|
||||
`;
|
||||
|
||||
const statsResult = await pool.query(statsQuery, [req.user.id, year]);
|
||||
const stats = statsResult.rows[0];
|
||||
|
||||
// Get monthly breakdown
|
||||
const monthlyQuery = `
|
||||
SELECT
|
||||
EXTRACT(MONTH FROM al.application_date) as month,
|
||||
COUNT(*) as applications,
|
||||
COALESCE(SUM(al.area_covered), 0) as area_covered
|
||||
FROM application_logs al
|
||||
WHERE al.user_id = $1
|
||||
AND EXTRACT(YEAR FROM al.application_date) = $2
|
||||
GROUP BY EXTRACT(MONTH FROM al.application_date)
|
||||
ORDER BY month
|
||||
`;
|
||||
|
||||
const monthlyResult = await pool.query(monthlyQuery, [req.user.id, year]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
stats: {
|
||||
totalApplications: parseInt(stats.total_applications) || 0,
|
||||
totalPlans: parseInt(stats.total_plans) || 0,
|
||||
completedPlans: parseInt(stats.completed_plans) || 0,
|
||||
plannedApplications: parseInt(stats.planned_applications) || 0,
|
||||
totalAreaTreated: parseFloat(stats.total_area_treated) || 0,
|
||||
avgApplicationSpeed: parseFloat(stats.avg_application_speed) || 0,
|
||||
completionRate: stats.total_plans > 0 ?
|
||||
Math.round((stats.completed_plans / stats.total_plans) * 100) : 0
|
||||
},
|
||||
monthlyBreakdown: monthlyResult.rows.map(row => ({
|
||||
month: parseInt(row.month),
|
||||
applications: parseInt(row.applications),
|
||||
areaCovered: parseFloat(row.area_covered)
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
313
backend/src/routes/auth.js
Normal file
313
backend/src/routes/auth.js
Normal 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;
|
||||
448
backend/src/routes/equipment.js
Normal file
448
backend/src/routes/equipment.js
Normal 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;
|
||||
537
backend/src/routes/products.js
Normal file
537
backend/src/routes/products.js
Normal 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;
|
||||
410
backend/src/routes/properties.js
Normal file
410
backend/src/routes/properties.js
Normal 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
225
backend/src/routes/users.js
Normal 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;
|
||||
454
backend/src/routes/weather.js
Normal file
454
backend/src/routes/weather.js
Normal 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;
|
||||
164
backend/src/utils/validation.js
Normal file
164
backend/src/utils/validation.js
Normal 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
|
||||
};
|
||||
Reference in New Issue
Block a user