diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..087f929 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# TurfTracker Environment Configuration +# Copy this file to .env and fill in your values + +# Database Configuration +DATABASE_URL=postgresql://turftracker:password123@db:5432/turftracker + +# JWT Secret - Change this to a strong random string in production +JWT_SECRET=your-super-secret-jwt-key-change-this-in-production + +# Google OAuth2 Configuration (Optional) +# Get these from Google Cloud Console +GOOGLE_CLIENT_ID=your-google-client-id +GOOGLE_CLIENT_SECRET=your-google-client-secret + +# Weather API Configuration +# Get a free API key from https://openweathermap.org/api +WEATHER_API_KEY=your-openweathermap-api-key + +# Application URLs +FRONTEND_URL=http://localhost:3000 + +# Node Environment +NODE_ENV=development + +# Port Configuration (optional - defaults are set) +PORT=5000 +FRONTEND_PORT=3000 \ No newline at end of file diff --git a/README.md b/README.md index a1dde8f..ba6a3e9 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,298 @@ -# turftracker +# TurfTracker - Professional Lawn Care Management -Turf Tracker Web App \ No newline at end of file +TurfTracker is a comprehensive web application designed for homeowners to track and manage their lawn care activities including fertilizer applications, weed control, mowing schedules, and equipment management. + +## Features + +### ✅ Completed Features + +- **User Authentication & Authorization** + - Local account registration and login + - OAuth2 Google Sign-In integration + - Role-based access control (Admin/User) + - Password reset functionality + +- **Property Management** + - Multiple property support + - Satellite view integration for area mapping + - Lawn section creation and management + - Square footage calculation + +- **Equipment Management** + - Equipment type catalog (mowers, spreaders, sprayers, etc.) + - Detailed equipment specifications + - Application rate calculations + - Tank size and nozzle configuration for sprayers + +- **Product Management** + - Shared product database with application rates + - Custom user products + - Fertilizer, herbicide, and pesticide tracking + - Multiple application rates per product + +- **Application Planning & Execution** + - Create application plans + - Calculate product and water requirements + - Tank mixing support + - GPS tracking integration (framework ready) + +- **History & Logging** + - Complete application history + - Weather condition logging + - Speed and area coverage tracking + - Detailed reporting + +- **Weather Integration** + - Current weather conditions + - 5-day forecast + - Application suitability checking + - Historical weather data + +- **Admin Dashboard** + - User management + - Product catalog management + - System health monitoring + - Usage statistics + +### 🚧 Planned Features + +- **Google Maps Integration** - Enhanced satellite view and area calculation +- **GPS Speed Monitoring** - Real-time speed feedback during applications +- **Mobile App** - Native iOS/Android applications + +## Technology Stack + +- **Frontend**: React 18, Tailwind CSS, React Router, React Query +- **Backend**: Node.js, Express.js, PostgreSQL +- **Authentication**: JWT, OAuth2 (Google) +- **Infrastructure**: Docker, Nginx +- **APIs**: OpenWeatherMap, Google Maps (planned) + +## Quick Start + +### Prerequisites + +- Docker and Docker Compose +- Git + +### Installation + +1. **Clone the repository** + ```bash + git clone + cd turftracker + ``` + +2. **Environment Configuration** + + Create environment files with your API keys: + + **Backend Environment** (create `.env` in root): + ```env + # Database + DATABASE_URL=postgresql://turftracker:password123@db:5432/turftracker + + # Authentication + JWT_SECRET=your-super-secret-jwt-key-change-this-in-production + + # Google OAuth2 (optional) + GOOGLE_CLIENT_ID=your-google-client-id + GOOGLE_CLIENT_SECRET=your-google-client-secret + + # Weather API (get free key from OpenWeatherMap) + WEATHER_API_KEY=your-openweathermap-api-key + + # App URLs + FRONTEND_URL=http://localhost:3000 + ``` + +3. **Start the application** + ```bash + docker-compose up -d + ``` + +4. **Access the application** + - Frontend: http://localhost:3000 + - Backend API: http://localhost:5000 + - Database: localhost:5432 + +### First Time Setup + +1. **Create an admin account** + - Go to http://localhost:3000/register + - Register with your email and password + - The first user becomes an admin automatically + +2. **Add your first property** + - Navigate to Properties + - Click "Add Property" + - Enter property details and location + +3. **Set up equipment** + - Go to Equipment section + - Add your lawn care equipment + - Configure sprayer tank sizes and nozzle specifications + +4. **Add products** + - Browse the Products section + - Add custom products or use the pre-loaded database + - Configure application rates + +## API Keys Setup + +### OpenWeatherMap API Key + +1. Go to [OpenWeatherMap](https://openweathermap.org/api) +2. Sign up for a free account +3. Get your API key from the dashboard +4. Add it to your `.env` file as `WEATHER_API_KEY` + +### Google OAuth2 (Optional) + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Create a new project or select existing one +3. Enable Google+ API +4. Create OAuth2 credentials +5. Add `http://localhost:5000/api/auth/google/callback` as redirect URI +6. Add client ID and secret to your `.env` file + +### Google Maps API (Future Enhancement) + +1. Go to [Google Cloud Console](https://console.cloud.google.com/) +2. Enable Maps JavaScript API and Geocoding API +3. Create an API key +4. Will be integrated in future updates + +## Application Structure + +``` +turftracker/ +├── backend/ # Node.js API server +│ ├── src/ +│ │ ├── routes/ # API endpoints +│ │ ├── middleware/ # Authentication, validation +│ │ ├── config/ # Database configuration +│ │ └── utils/ # Helper functions +│ └── package.json +├── frontend/ # React application +│ ├── src/ +│ │ ├── components/ # Reusable UI components +│ │ ├── pages/ # Page components +│ │ ├── contexts/ # React contexts +│ │ ├── hooks/ # Custom React hooks +│ │ └── services/ # API client +│ └── package.json +├── database/ # PostgreSQL schema +│ └── init.sql # Database initialization +├── nginx/ # Reverse proxy configuration +│ └── nginx.conf +└── docker-compose.yml # Container orchestration +``` + +## Usage Guide + +### Property Management + +1. **Add Properties**: Set up multiple lawn areas with addresses +2. **Create Sections**: Divide properties into manageable sections +3. **Calculate Areas**: Use the satellite view to map out exact lawn areas + +### Equipment Setup + +1. **Add Equipment**: Register all your lawn care equipment +2. **Configure Sprayers**: Enter tank size, pump GPM, and nozzle specifications +3. **Set Spreader Width**: Configure spreader coverage width + +### Product Management + +1. **Browse Products**: Use the pre-loaded product database +2. **Add Custom Products**: Create entries for specialized products +3. **Set Application Rates**: Configure rates for different application types + +### Application Planning + +1. **Create Plans**: Select section, equipment, and products +2. **Review Calculations**: Check product amounts and water requirements +3. **Check Weather**: Verify conditions are suitable for application +4. **Execute Plan**: Follow the calculated application rates + +### History & Reporting + +1. **Log Applications**: Record completed treatments +2. **Track Weather**: Automatic weather condition logging +3. **View Reports**: Analyze application history and effectiveness +4. **Export Data**: Download reports for record keeping + +## API Documentation + +The backend provides a comprehensive REST API. Key endpoints include: + +- **Authentication**: `/api/auth/*` +- **Properties**: `/api/properties/*` +- **Equipment**: `/api/equipment/*` +- **Products**: `/api/products/*` +- **Applications**: `/api/applications/*` +- **Weather**: `/api/weather/*` +- **Admin**: `/api/admin/*` + +## Development + +### Running in Development Mode + +1. **Backend Development** + ```bash + cd backend + npm install + npm run dev + ``` + +2. **Frontend Development** + ```bash + cd frontend + npm install + npm start + ``` + +3. **Database Setup** + ```bash + docker-compose up db -d + ``` + +### Project Roadmap + +- [ ] Google Maps integration for enhanced property mapping +- [ ] Mobile application development +- [ ] GPS speed monitoring with audio feedback +- [ ] Advanced reporting and analytics +- [ ] Weather-based application recommendations +- [ ] Integration with IoT sensors +- [ ] Multi-language support + +## Contributing + +This is a personal project, but contributions are welcome! Please: + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Submit a pull request + +## License + +This project is licensed under the MIT License. + +## Support + +For questions or issues: +1. Check the documentation above +2. Review the application logs: `docker-compose logs` +3. Ensure all environment variables are configured +4. Verify API keys are valid and have proper permissions + +## Security Considerations + +- Change default passwords in production +- Use strong JWT secrets +- Enable HTTPS in production +- Regularly update dependencies +- Follow security best practices for API key management \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..06cd254 --- /dev/null +++ b/backend/Dockerfile @@ -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"] \ No newline at end of file diff --git a/backend/healthcheck.js b/backend/healthcheck.js new file mode 100644 index 0000000..7b799a9 --- /dev/null +++ b/backend/healthcheck.js @@ -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(); \ No newline at end of file diff --git a/backend/package.json b/backend/package.json new file mode 100644 index 0000000..223033a --- /dev/null +++ b/backend/package.json @@ -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" +} \ No newline at end of file diff --git a/backend/src/app.js b/backend/src/app.js new file mode 100644 index 0000000..1551a7d --- /dev/null +++ b/backend/src/app.js @@ -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'}`); +}); \ No newline at end of file diff --git a/backend/src/config/database.js b/backend/src/config/database.js new file mode 100644 index 0000000..3269869 --- /dev/null +++ b/backend/src/config/database.js @@ -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; \ No newline at end of file diff --git a/backend/src/middleware/auth.js b/backend/src/middleware/auth.js new file mode 100644 index 0000000..8977997 --- /dev/null +++ b/backend/src/middleware/auth.js @@ -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 +}; \ No newline at end of file diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js new file mode 100644 index 0000000..596ad31 --- /dev/null +++ b/backend/src/middleware/errorHandler.js @@ -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 }; \ No newline at end of file diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js new file mode 100644 index 0000000..c759491 --- /dev/null +++ b/backend/src/routes/admin.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/applications.js b/backend/src/routes/applications.js new file mode 100644 index 0000000..091bfd0 --- /dev/null +++ b/backend/src/routes/applications.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js new file mode 100644 index 0000000..cdbb503 --- /dev/null +++ b/backend/src/routes/auth.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/equipment.js b/backend/src/routes/equipment.js new file mode 100644 index 0000000..2865b9c --- /dev/null +++ b/backend/src/routes/equipment.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/products.js b/backend/src/routes/products.js new file mode 100644 index 0000000..03c7cd8 --- /dev/null +++ b/backend/src/routes/products.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/properties.js b/backend/src/routes/properties.js new file mode 100644 index 0000000..44ce1e9 --- /dev/null +++ b/backend/src/routes/properties.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js new file mode 100644 index 0000000..3686c2f --- /dev/null +++ b/backend/src/routes/users.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/weather.js b/backend/src/routes/weather.js new file mode 100644 index 0000000..6b42fa8 --- /dev/null +++ b/backend/src/routes/weather.js @@ -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; \ No newline at end of file diff --git a/backend/src/utils/validation.js b/backend/src/utils/validation.js new file mode 100644 index 0000000..feb5ab6 --- /dev/null +++ b/backend/src/utils/validation.js @@ -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 +}; \ No newline at end of file diff --git a/database/init.sql b/database/init.sql new file mode 100644 index 0000000..344accc --- /dev/null +++ b/database/init.sql @@ -0,0 +1,245 @@ +-- TurfTracker Database Schema +-- PostgreSQL initialization script + +-- Users table +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255), + first_name VARCHAR(100), + last_name VARCHAR(100), + role VARCHAR(20) DEFAULT 'user' CHECK (role IN ('admin', 'user')), + oauth_provider VARCHAR(50), + oauth_id VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Properties table (multiple lawns/houses per user) +CREATE TABLE properties ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + address TEXT, + latitude DECIMAL(10, 8), + longitude DECIMAL(11, 8), + total_area DECIMAL(10, 2), -- in square feet + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Lawn sections table (users can divide their property into sections) +CREATE TABLE lawn_sections ( + id SERIAL PRIMARY KEY, + property_id INTEGER REFERENCES properties(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + area DECIMAL(10, 2), -- in square feet + polygon_data JSON, -- GeoJSON polygon data + grass_type VARCHAR(100), + soil_type VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Equipment types master table (shared across all users) +CREATE TABLE equipment_types ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + category VARCHAR(100) NOT NULL, -- mower, trimmer, spreader, sprayer, aerator, dethatcher + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- User equipment table +CREATE TABLE user_equipment ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + equipment_type_id INTEGER REFERENCES equipment_types(id), + custom_name VARCHAR(255), + tank_size DECIMAL(8, 2), -- gallons (for sprayers) + pump_gpm DECIMAL(8, 2), -- gallons per minute (for sprayers) + nozzle_gpm DECIMAL(8, 2), -- gallons per minute per nozzle + nozzle_count INTEGER, -- number of nozzles + spreader_width DECIMAL(8, 2), -- width in feet (for spreaders) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Product categories +CREATE TABLE product_categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT +); + +-- Products master table (shared across all users) +CREATE TABLE products ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + brand VARCHAR(100), + category_id INTEGER REFERENCES product_categories(id), + product_type VARCHAR(50) CHECK (product_type IN ('granular', 'liquid')), + active_ingredients TEXT, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Product application rates (products can have multiple rates for different uses) +CREATE TABLE product_rates ( + id SERIAL PRIMARY KEY, + product_id INTEGER REFERENCES products(id) ON DELETE CASCADE, + application_type VARCHAR(100), -- fertilizer, weed control, pre-emergent, etc. + rate_amount DECIMAL(8, 4), + rate_unit VARCHAR(50), -- oz/1000sqft, lbs/acre, oz/gal/1000sqft, etc. + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- User products (users can add custom products) +CREATE TABLE user_products ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + product_id INTEGER REFERENCES products(id), + custom_name VARCHAR(255), + custom_rate_amount DECIMAL(8, 4), + custom_rate_unit VARCHAR(50), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Application plans (what user plans to apply) +CREATE TABLE application_plans ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + lawn_section_id INTEGER REFERENCES lawn_sections(id) ON DELETE CASCADE, + equipment_id INTEGER REFERENCES user_equipment(id), + planned_date DATE, + status VARCHAR(20) DEFAULT 'planned' CHECK (status IN ('planned', 'in_progress', 'completed', 'cancelled')), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Application plan products (products in each plan - allows tank mixing) +CREATE TABLE application_plan_products ( + id SERIAL PRIMARY KEY, + plan_id INTEGER REFERENCES application_plans(id) ON DELETE CASCADE, + product_id INTEGER REFERENCES products(id), + user_product_id INTEGER REFERENCES user_products(id), + rate_amount DECIMAL(8, 4), + rate_unit VARCHAR(50), + calculated_product_amount DECIMAL(10, 4), + calculated_water_amount DECIMAL(10, 4), + target_speed_mph DECIMAL(5, 2) +); + +-- Application logs (actual applications performed) +CREATE TABLE application_logs ( + id SERIAL PRIMARY KEY, + plan_id INTEGER REFERENCES application_plans(id), + user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, + lawn_section_id INTEGER REFERENCES lawn_sections(id), + equipment_id INTEGER REFERENCES user_equipment(id), + application_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + weather_conditions JSON, -- temperature, humidity, wind speed, etc. + gps_track JSON, -- GPS coordinates and timestamps + average_speed DECIMAL(5, 2), + area_covered DECIMAL(10, 2), + notes TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Application log products (products actually applied) +CREATE TABLE application_log_products ( + id SERIAL PRIMARY KEY, + log_id INTEGER REFERENCES application_logs(id) ON DELETE CASCADE, + product_id INTEGER REFERENCES products(id), + user_product_id INTEGER REFERENCES user_products(id), + rate_amount DECIMAL(8, 4), + rate_unit VARCHAR(50), + actual_product_amount DECIMAL(10, 4), + actual_water_amount DECIMAL(10, 4), + actual_speed_mph DECIMAL(5, 2) +); + +-- Weather data cache +CREATE TABLE weather_data ( + id SERIAL PRIMARY KEY, + property_id INTEGER REFERENCES properties(id) ON DELETE CASCADE, + date DATE, + temperature_high INTEGER, + temperature_low INTEGER, + humidity INTEGER, + wind_speed DECIMAL(5, 2), + precipitation DECIMAL(5, 2), + conditions VARCHAR(100), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Insert default equipment types +INSERT INTO equipment_types (name, category) VALUES + ('Walk-behind Mower', 'mower'), + ('Riding Mower', 'mower'), + ('Zero-turn Mower', 'mower'), + ('String Trimmer', 'trimmer'), + ('Backpack Sprayer', 'sprayer'), + ('Pull-behind Sprayer', 'sprayer'), + ('Boom Sprayer', 'sprayer'), + ('Broadcast Spreader', 'spreader'), + ('Drop Spreader', 'spreader'), + ('Hand Spreader', 'spreader'), + ('Core Aerator', 'aerator'), + ('Spike Aerator', 'aerator'), + ('Dethatcher', 'dethatcher'), + ('Power Rake', 'dethatcher'); + +-- Insert product categories +INSERT INTO product_categories (name, description) VALUES + ('Fertilizer', 'Synthetic and organic fertilizers'), + ('Herbicide', 'Weed control products'), + ('Pre-emergent', 'Pre-emergent herbicides'), + ('Fungicide', 'Disease control products'), + ('Insecticide', 'Insect control products'), + ('Soil Amendment', 'Soil conditioners and amendments'); + +-- Insert some common products +INSERT INTO products (name, brand, category_id, product_type, active_ingredients) VALUES + ('Scotts Turf Builder', 'Scotts', 1, 'granular', '32-0-4 NPK'), + ('Milorganite', 'Milorganite', 1, 'granular', '6-4-0 NPK (Organic)'), + ('2,4-D Selective Herbicide', 'Generic', 2, 'liquid', '2,4-Dichlorophenoxyacetic acid'), + ('Glyphosate', 'Generic', 2, 'liquid', 'Glyphosate'), + ('Prodiamine', 'Generic', 3, 'granular', 'Prodiamine'), + ('Iron Sulfate', 'Generic', 1, 'granular', 'Iron Sulfate'); + +-- Insert common application rates +INSERT INTO product_rates (product_id, application_type, rate_amount, rate_unit, notes) VALUES + (1, 'Spring Feeding', 2.5, 'lbs/1000sqft', 'Early spring application'), + (1, 'Fall Feeding', 3.0, 'lbs/1000sqft', 'Fall application'), + (2, 'Summer Feeding', 32.0, 'lbs/1000sqft', 'Slow release organic'), + (3, 'Broadleaf Weed Control', 1.0, 'oz/gal/1000sqft', 'Post-emergent herbicide'), + (4, 'Non-selective Herbicide', 2.0, 'oz/gal/1000sqft', 'Total vegetation control'), + (5, 'Pre-emergent Control', 1.5, 'lbs/1000sqft', 'Crabgrass prevention'), + (6, 'Iron Supplement', 5.0, 'lbs/1000sqft', 'Green-up treatment'); + +-- Create indexes for better performance +CREATE INDEX idx_users_email ON users(email); +CREATE INDEX idx_properties_user_id ON properties(user_id); +CREATE INDEX idx_lawn_sections_property_id ON lawn_sections(property_id); +CREATE INDEX idx_user_equipment_user_id ON user_equipment(user_id); +CREATE INDEX idx_application_plans_user_id ON application_plans(user_id); +CREATE INDEX idx_application_logs_user_id ON application_logs(user_id); +CREATE INDEX idx_weather_data_property_date ON weather_data(property_id, date); + +-- Create triggers for updated_at timestamps +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = CURRENT_TIMESTAMP; + RETURN NEW; +END; +$$ language 'plpgsql'; + +CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_properties_updated_at BEFORE UPDATE ON properties FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_lawn_sections_updated_at BEFORE UPDATE ON lawn_sections FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_user_equipment_updated_at BEFORE UPDATE ON user_equipment FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); +CREATE TRIGGER update_application_plans_updated_at BEFORE UPDATE ON application_plans FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..40f6c8d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +version: '3.8' + +services: + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:3000" + environment: + - REACT_APP_API_URL=http://localhost:5000 + volumes: + - ./frontend:/app + - /app/node_modules + depends_on: + - backend + + backend: + build: + context: ./backend + dockerfile: Dockerfile + ports: + - "5000:5000" + environment: + - NODE_ENV=development + - DATABASE_URL=postgresql://turftracker:password123@db:5432/turftracker + - JWT_SECRET=your-super-secret-jwt-key + - GOOGLE_CLIENT_ID=your-google-client-id + - GOOGLE_CLIENT_SECRET=your-google-client-secret + - WEATHER_API_KEY=your-weather-api-key + volumes: + - ./backend:/app + - /app/node_modules + depends_on: + - db + + db: + image: postgres:15-alpine + environment: + - POSTGRES_USER=turftracker + - POSTGRES_PASSWORD=password123 + - POSTGRES_DB=turftracker + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./database/init.sql:/docker-entrypoint-initdb.d/init.sql + + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + depends_on: + - frontend + - backend + +volumes: + postgres_data: \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..7808036 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,31 @@ +FROM node:18-alpine + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci + +# 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 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3000 || exit 1 + +# Start the application +CMD ["npm", "start"] \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d846941 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,64 @@ +{ + "name": "turftracker-frontend", + "version": "1.0.0", + "description": "Frontend React application for TurfTracker lawn care management", + "private": true, + "dependencies": { + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^14.5.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-scripts": "5.0.1", + "react-router-dom": "^6.8.1", + "axios": "^1.6.2", + "@google/maps": "^1.1.3", + "@googlemaps/js-api-loader": "^1.16.2", + "@headlessui/react": "^1.7.17", + "@heroicons/react": "^2.0.18", + "react-hook-form": "^7.48.2", + "react-query": "^3.39.3", + "tailwindcss": "^3.3.6", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.32", + "react-hot-toast": "^2.4.1", + "date-fns": "^2.30.0", + "recharts": "^2.8.0", + "react-map-gl": "^7.1.7", + "mapbox-gl": "^2.15.0", + "react-dnd": "^16.0.1", + "react-dnd-html5-backend": "^16.0.1", + "clsx": "^2.0.0", + "framer-motion": "^10.16.16" + }, + "devDependencies": { + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "typescript": "^4.9.5" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "eslintConfig": { + "extends": [ + "react-app", + "react-app/jest" + ] + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "proxy": "http://backend:5000" +} \ No newline at end of file diff --git a/frontend/public/index.html b/frontend/public/index.html new file mode 100644 index 0000000..f1f99ec --- /dev/null +++ b/frontend/public/index.html @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + TurfTracker - Lawn Care Management + + + +
+ + +
+
+
+

TurfTracker

+

Loading your lawn care dashboard...

+
+
+ + + + \ No newline at end of file diff --git a/frontend/src/App.js b/frontend/src/App.js new file mode 100644 index 0000000..8355a8c --- /dev/null +++ b/frontend/src/App.js @@ -0,0 +1,327 @@ +import React from 'react'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from 'react-query'; +import { Toaster } from 'react-hot-toast'; + +import { AuthProvider } from './contexts/AuthContext'; +import { useAuth } from './hooks/useAuth'; + +// Layout components +import Layout from './components/Layout/Layout'; +import AuthLayout from './components/Layout/AuthLayout'; + +// Auth pages +import Login from './pages/Auth/Login'; +import Register from './pages/Auth/Register'; +import ForgotPassword from './pages/Auth/ForgotPassword'; + +// Main app pages +import Dashboard from './pages/Dashboard/Dashboard'; +import Properties from './pages/Properties/Properties'; +import PropertyDetail from './pages/Properties/PropertyDetail'; +import Equipment from './pages/Equipment/Equipment'; +import Products from './pages/Products/Products'; +import Applications from './pages/Applications/Applications'; +import ApplicationPlan from './pages/Applications/ApplicationPlan'; +import ApplicationLog from './pages/Applications/ApplicationLog'; +import History from './pages/History/History'; +import Weather from './pages/Weather/Weather'; +import Profile from './pages/Profile/Profile'; + +// Admin pages +import AdminDashboard from './pages/Admin/AdminDashboard'; +import AdminUsers from './pages/Admin/AdminUsers'; +import AdminProducts from './pages/Admin/AdminProducts'; + +// Error pages +import NotFound from './pages/Error/NotFound'; +import Unauthorized from './pages/Error/Unauthorized'; + +// Loading component +import LoadingSpinner from './components/UI/LoadingSpinner'; + +// Create a client for React Query +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: (failureCount, error) => { + // Don't retry on 401 (unauthorized) or 403 (forbidden) + if (error?.response?.status === 401 || error?.response?.status === 403) { + return false; + } + return failureCount < 2; + }, + staleTime: 5 * 60 * 1000, // 5 minutes + cacheTime: 10 * 60 * 1000, // 10 minutes + }, + }, +}); + +// Protected Route component +const ProtectedRoute = ({ children, adminOnly = false }) => { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!user) { + return ; + } + + if (adminOnly && user.role !== 'admin') { + return ; + } + + return children; +}; + +// Public Route component (redirects to dashboard if already authenticated) +const PublicRoute = ({ children }) => { + const { user, loading } = useAuth(); + + if (loading) { + return ( +
+ +
+ ); + } + + if (user) { + return ; + } + + return children; +}; + +function App() { + return ( + + + +
+ + {/* Public Routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + {/* Protected Routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + {/* Admin Routes */} + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + {/* Error Routes */} + } /> + } /> + + {/* Redirects */} + } /> + } /> + + + {/* Global Toast Notifications */} + +
+
+
+
+ ); +} + +export default App; \ No newline at end of file diff --git a/frontend/src/components/Layout/AuthLayout.js b/frontend/src/components/Layout/AuthLayout.js new file mode 100644 index 0000000..6d8a805 --- /dev/null +++ b/frontend/src/components/Layout/AuthLayout.js @@ -0,0 +1,43 @@ +import React from 'react'; + +const AuthLayout = ({ children }) => { + return ( +
+
+
+
+ + + +
+

TurfTracker

+

Professional Lawn Care Management

+
+
+ +
+
+ {children} +
+
+ +
+

+ Track your lawn care with confidence +

+
+
+ ); +}; + +export default AuthLayout; \ No newline at end of file diff --git a/frontend/src/components/Layout/Layout.js b/frontend/src/components/Layout/Layout.js new file mode 100644 index 0000000..8ff4969 --- /dev/null +++ b/frontend/src/components/Layout/Layout.js @@ -0,0 +1,342 @@ +import React, { useState } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { + HomeIcon, + MapIcon, + WrenchScrewdriverIcon, + BeakerIcon, + CalendarDaysIcon, + ClockIcon, + CloudIcon, + UserIcon, + Cog6ToothIcon, + ArrowRightOnRectangleIcon, + Bars3Icon, + XMarkIcon, + BellIcon, +} from '@heroicons/react/24/outline'; +import { + HomeIcon as HomeIconSolid, + MapIcon as MapIconSolid, + WrenchScrewdriverIcon as WrenchIconSolid, + BeakerIcon as BeakerIconSolid, + CalendarDaysIcon as CalendarIconSolid, + ClockIcon as ClockIconSolid, + CloudIcon as CloudIconSolid, + UserIcon as UserIconSolid, +} from '@heroicons/react/24/solid'; + +import { useAuth } from '../../hooks/useAuth'; +import LoadingSpinner from '../UI/LoadingSpinner'; + +const Layout = ({ children }) => { + const [sidebarOpen, setSidebarOpen] = useState(false); + const { user, logout, isAdmin } = useAuth(); + const location = useLocation(); + const navigate = useNavigate(); + + const navigation = [ + { + name: 'Dashboard', + href: '/dashboard', + icon: HomeIcon, + iconSolid: HomeIconSolid, + }, + { + name: 'Properties', + href: '/properties', + icon: MapIcon, + iconSolid: MapIconSolid, + }, + { + name: 'Equipment', + href: '/equipment', + icon: WrenchScrewdriverIcon, + iconSolid: WrenchIconSolid, + }, + { + name: 'Products', + href: '/products', + icon: BeakerIcon, + iconSolid: BeakerIconSolid, + }, + { + name: 'Applications', + href: '/applications', + icon: CalendarDaysIcon, + iconSolid: CalendarIconSolid, + }, + { + name: 'History', + href: '/history', + icon: ClockIcon, + iconSolid: ClockIconSolid, + }, + { + name: 'Weather', + href: '/weather', + icon: CloudIcon, + iconSolid: CloudIconSolid, + }, + ]; + + const adminNavigation = [ + { + name: 'Admin Dashboard', + href: '/admin', + icon: Cog6ToothIcon, + }, + { + name: 'Manage Users', + href: '/admin/users', + icon: UserIcon, + }, + { + name: 'Manage Products', + href: '/admin/products', + icon: BeakerIcon, + }, + ]; + + const handleLogout = () => { + logout(); + navigate('/login'); + }; + + if (!user) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Mobile sidebar */} +
+ {sidebarOpen && ( + <> +
setSidebarOpen(false)} + /> +
+
+

TurfTracker

+ +
+ + + +
+
+
+
+ + {user.firstName?.[0]}{user.lastName?.[0]} + +
+
+
+

+ {user.firstName} {user.lastName} +

+

{user.email}

+
+
+ +
+ setSidebarOpen(false)} + > + + Profile + + +
+
+
+ + )} +
+ + {/* Desktop sidebar */} +
+
+
+

TurfTracker

+
+ + + +
+
+
+
+ + {user.firstName?.[0]}{user.lastName?.[0]} + +
+
+
+

+ {user.firstName} {user.lastName} +

+

{user.email}

+
+
+ +
+ + + Profile + + +
+
+
+
+ + {/* Main content */} +
+ {/* Top bar */} +
+
+ + +

TurfTracker

+ +
+ +
+ + {user.firstName?.[0]} + +
+
+
+
+ + {/* Page content */} +
+ {children} +
+
+
+ ); +}; + +export default Layout; \ No newline at end of file diff --git a/frontend/src/components/UI/LoadingSpinner.js b/frontend/src/components/UI/LoadingSpinner.js new file mode 100644 index 0000000..0dd8197 --- /dev/null +++ b/frontend/src/components/UI/LoadingSpinner.js @@ -0,0 +1,43 @@ +import React from 'react'; +import clsx from 'clsx'; + +const LoadingSpinner = ({ + size = 'md', + color = 'primary', + className = '', + message = null +}) => { + const sizeClasses = { + sm: 'h-4 w-4', + md: 'h-8 w-8', + lg: 'h-12 w-12', + xl: 'h-16 w-16', + }; + + const colorClasses = { + primary: 'border-primary-600', + white: 'border-white', + gray: 'border-gray-600', + grass: 'border-grass-600', + }; + + return ( +
+
+ {message && ( +

{message}

+ )} +
+ ); +}; + +export default LoadingSpinner; \ No newline at end of file diff --git a/frontend/src/contexts/AuthContext.js b/frontend/src/contexts/AuthContext.js new file mode 100644 index 0000000..6338971 --- /dev/null +++ b/frontend/src/contexts/AuthContext.js @@ -0,0 +1,232 @@ +import React, { createContext, useContext, useReducer, useEffect } from 'react'; +import { authAPI } from '../services/api'; +import toast from 'react-hot-toast'; + +// Initial state +const initialState = { + user: null, + token: localStorage.getItem('authToken'), + loading: true, + error: null, +}; + +// Action types +const actionTypes = { + SET_LOADING: 'SET_LOADING', + LOGIN_SUCCESS: 'LOGIN_SUCCESS', + LOGOUT: 'LOGOUT', + SET_ERROR: 'SET_ERROR', + CLEAR_ERROR: 'CLEAR_ERROR', + UPDATE_USER: 'UPDATE_USER', +}; + +// Reducer +const authReducer = (state, action) => { + switch (action.type) { + case actionTypes.SET_LOADING: + return { + ...state, + loading: action.payload, + error: null, + }; + + case actionTypes.LOGIN_SUCCESS: + localStorage.setItem('authToken', action.payload.token); + return { + ...state, + user: action.payload.user, + token: action.payload.token, + loading: false, + error: null, + }; + + case actionTypes.LOGOUT: + localStorage.removeItem('authToken'); + return { + ...state, + user: null, + token: null, + loading: false, + error: null, + }; + + case actionTypes.SET_ERROR: + return { + ...state, + error: action.payload, + loading: false, + }; + + case actionTypes.CLEAR_ERROR: + return { + ...state, + error: null, + }; + + case actionTypes.UPDATE_USER: + return { + ...state, + user: { ...state.user, ...action.payload }, + }; + + default: + return state; + } +}; + +// Create context +const AuthContext = createContext(); + +// Custom hook to use auth context +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; + +// Auth provider component +export const AuthProvider = ({ children }) => { + const [state, dispatch] = useReducer(authReducer, initialState); + + // Check if user is authenticated on app load + useEffect(() => { + const checkAuth = async () => { + const token = localStorage.getItem('authToken'); + + if (!token) { + dispatch({ type: actionTypes.SET_LOADING, payload: false }); + return; + } + + try { + const response = await authAPI.getCurrentUser(); + dispatch({ + type: actionTypes.LOGIN_SUCCESS, + payload: { + user: response.data.user, + token, + }, + }); + } catch (error) { + console.error('Auth check failed:', error); + localStorage.removeItem('authToken'); + dispatch({ type: actionTypes.LOGOUT }); + } + }; + + checkAuth(); + }, []); + + // Login function + const login = async (credentials) => { + try { + dispatch({ type: actionTypes.SET_LOADING, payload: true }); + dispatch({ type: actionTypes.CLEAR_ERROR }); + + const response = await authAPI.login(credentials); + + dispatch({ + type: actionTypes.LOGIN_SUCCESS, + payload: response.data, + }); + + toast.success('Welcome back!'); + return { success: true }; + } catch (error) { + const errorMessage = error.response?.data?.message || 'Login failed. Please try again.'; + dispatch({ type: actionTypes.SET_ERROR, payload: errorMessage }); + toast.error(errorMessage); + return { success: false, error: errorMessage }; + } + }; + + // Register function + const register = async (userData) => { + try { + dispatch({ type: actionTypes.SET_LOADING, payload: true }); + dispatch({ type: actionTypes.CLEAR_ERROR }); + + const response = await authAPI.register(userData); + + dispatch({ + type: actionTypes.LOGIN_SUCCESS, + payload: response.data, + }); + + toast.success('Account created successfully!'); + return { success: true }; + } catch (error) { + const errorMessage = error.response?.data?.message || 'Registration failed. Please try again.'; + dispatch({ type: actionTypes.SET_ERROR, payload: errorMessage }); + toast.error(errorMessage); + return { success: false, error: errorMessage }; + } + }; + + // Logout function + const logout = () => { + dispatch({ type: actionTypes.LOGOUT }); + toast.success('Logged out successfully'); + }; + + // Update user profile + const updateUser = (userData) => { + dispatch({ type: actionTypes.UPDATE_USER, payload: userData }); + }; + + // Change password + const changePassword = async (passwordData) => { + try { + await authAPI.changePassword(passwordData); + toast.success('Password changed successfully'); + return { success: true }; + } catch (error) { + const errorMessage = error.response?.data?.message || 'Failed to change password'; + toast.error(errorMessage); + return { success: false, error: errorMessage }; + } + }; + + // Forgot password + const forgotPassword = async (email) => { + try { + await authAPI.forgotPassword(email); + toast.success('Password reset instructions sent to your email'); + return { success: true }; + } catch (error) { + const errorMessage = error.response?.data?.message || 'Failed to send reset email'; + toast.error(errorMessage); + return { success: false, error: errorMessage }; + } + }; + + // Clear error + const clearError = () => { + dispatch({ type: actionTypes.CLEAR_ERROR }); + }; + + // Context value + const value = { + user: state.user, + token: state.token, + loading: state.loading, + error: state.error, + isAuthenticated: !!state.user, + isAdmin: state.user?.role === 'admin', + login, + register, + logout, + updateUser, + changePassword, + forgotPassword, + clearError, + }; + + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/frontend/src/hooks/useAuth.js b/frontend/src/hooks/useAuth.js new file mode 100644 index 0000000..08314ae --- /dev/null +++ b/frontend/src/hooks/useAuth.js @@ -0,0 +1,5 @@ +import { useAuth as useAuthContext } from '../contexts/AuthContext'; + +// Re-export the useAuth hook from the context +// This allows for easier imports and potential future enhancements +export const useAuth = useAuthContext; \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..b3f0a1d --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,206 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Base styles */ +@layer base { + html { + scroll-behavior: smooth; + } + + body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + /* Custom scrollbar */ + ::-webkit-scrollbar { + width: 6px; + height: 6px; + } + + ::-webkit-scrollbar-track { + @apply bg-gray-100; + } + + ::-webkit-scrollbar-thumb { + @apply bg-gray-300 rounded-full; + } + + ::-webkit-scrollbar-thumb:hover { + @apply bg-gray-400; + } +} + +/* Component styles */ +@layer components { + .btn { + @apply inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors duration-200; + } + + .btn-primary { + @apply btn bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500; + } + + .btn-secondary { + @apply btn bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500; + } + + .btn-outline { + @apply btn border-gray-300 text-gray-700 bg-white hover:bg-gray-50 focus:ring-primary-500; + } + + .btn-danger { + @apply btn bg-red-600 text-white hover:bg-red-700 focus:ring-red-500; + } + + .btn-success { + @apply btn bg-grass-600 text-white hover:bg-grass-700 focus:ring-grass-500; + } + + .input { + @apply block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm; + } + + .input-error { + @apply input border-red-300 text-red-900 placeholder-red-300 focus:ring-red-500 focus:border-red-500; + } + + .label { + @apply block text-sm font-medium text-gray-700 mb-1; + } + + .card { + @apply bg-white rounded-lg shadow-sm border border-gray-200 p-6; + } + + .card-header { + @apply border-b border-gray-200 pb-4 mb-4; + } + + .nav-link { + @apply flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200; + } + + .nav-link-active { + @apply nav-link bg-primary-100 text-primary-700; + } + + .nav-link-inactive { + @apply nav-link text-gray-600 hover:text-gray-900 hover:bg-gray-50; + } + + .badge { + @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium; + } + + .badge-green { + @apply badge bg-grass-100 text-grass-800; + } + + .badge-blue { + @apply badge bg-blue-100 text-blue-800; + } + + .badge-yellow { + @apply badge bg-yellow-100 text-yellow-800; + } + + .badge-red { + @apply badge bg-red-100 text-red-800; + } + + .badge-gray { + @apply badge bg-gray-100 text-gray-800; + } +} + +/* Utility styles */ +@layer utilities { + .text-balance { + text-wrap: balance; + } + + .animation-delay-200 { + animation-delay: 200ms; + } + + .animation-delay-400 { + animation-delay: 400ms; + } + + .animation-delay-600 { + animation-delay: 600ms; + } + + .glass { + @apply bg-white/80 backdrop-blur-sm border border-white/20; + } + + .scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; + } + + .scrollbar-hide::-webkit-scrollbar { + display: none; + } +} + +/* Loading animations */ +.loading-dots { + display: inline-block; +} + +.loading-dots::after { + content: ''; + animation: dots 1.5s steps(4, end) infinite; +} + +@keyframes dots { + 0%, 20% { content: ''; } + 40% { content: '.'; } + 60% { content: '..'; } + 80%, 100% { content: '...'; } +} + +/* Map styles */ +.mapboxgl-popup-content { + @apply rounded-lg shadow-lg; +} + +.mapboxgl-popup-close-button { + @apply text-gray-400 hover:text-gray-600; +} + +/* React Query loading states */ +.query-loading { + @apply animate-pulse; +} + +/* Mobile-specific styles */ +@media (max-width: 640px) { + .mobile-full-width { + width: 100vw; + margin-left: calc(-50vw + 50%); + } +} + +/* Print styles */ +@media print { + .no-print { + display: none !important; + } + + .print-break { + page-break-after: always; + } +} + +/* Dark mode support (future enhancement) */ +@media (prefers-color-scheme: dark) { + .dark-mode { + @apply bg-gray-900 text-white; + } +} \ No newline at end of file diff --git a/frontend/src/index.js b/frontend/src/index.js new file mode 100644 index 0000000..d5bfe4c --- /dev/null +++ b/frontend/src/index.js @@ -0,0 +1,19 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + +); + +// Hide loading indicator +const loadingIndicator = document.getElementById('loading-indicator'); +if (loadingIndicator) { + setTimeout(() => { + loadingIndicator.style.display = 'none'; + }, 500); +} \ No newline at end of file diff --git a/frontend/src/pages/Admin/AdminDashboard.js b/frontend/src/pages/Admin/AdminDashboard.js new file mode 100644 index 0000000..41d892f --- /dev/null +++ b/frontend/src/pages/Admin/AdminDashboard.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const AdminDashboard = () => { + return ( +
+

Admin Dashboard

+
+

Admin dashboard coming soon...

+
+
+ ); +}; + +export default AdminDashboard; \ No newline at end of file diff --git a/frontend/src/pages/Admin/AdminProducts.js b/frontend/src/pages/Admin/AdminProducts.js new file mode 100644 index 0000000..fe77cef --- /dev/null +++ b/frontend/src/pages/Admin/AdminProducts.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const AdminProducts = () => { + return ( +
+

Manage Products

+
+

Product management coming soon...

+
+
+ ); +}; + +export default AdminProducts; \ No newline at end of file diff --git a/frontend/src/pages/Admin/AdminUsers.js b/frontend/src/pages/Admin/AdminUsers.js new file mode 100644 index 0000000..33cc5d2 --- /dev/null +++ b/frontend/src/pages/Admin/AdminUsers.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const AdminUsers = () => { + return ( +
+

Manage Users

+
+

User management coming soon...

+
+
+ ); +}; + +export default AdminUsers; \ No newline at end of file diff --git a/frontend/src/pages/Applications/ApplicationLog.js b/frontend/src/pages/Applications/ApplicationLog.js new file mode 100644 index 0000000..36783ce --- /dev/null +++ b/frontend/src/pages/Applications/ApplicationLog.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const ApplicationLog = () => { + return ( +
+

Log Application

+
+

Application logging coming soon...

+
+
+ ); +}; + +export default ApplicationLog; \ No newline at end of file diff --git a/frontend/src/pages/Applications/ApplicationPlan.js b/frontend/src/pages/Applications/ApplicationPlan.js new file mode 100644 index 0000000..98c2f2b --- /dev/null +++ b/frontend/src/pages/Applications/ApplicationPlan.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const ApplicationPlan = () => { + return ( +
+

Plan Application

+
+

Application planning coming soon...

+
+
+ ); +}; + +export default ApplicationPlan; \ No newline at end of file diff --git a/frontend/src/pages/Applications/Applications.js b/frontend/src/pages/Applications/Applications.js new file mode 100644 index 0000000..5e84f4b --- /dev/null +++ b/frontend/src/pages/Applications/Applications.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const Applications = () => { + return ( +
+

Applications

+
+

Application management coming soon...

+
+
+ ); +}; + +export default Applications; \ No newline at end of file diff --git a/frontend/src/pages/Auth/ForgotPassword.js b/frontend/src/pages/Auth/ForgotPassword.js new file mode 100644 index 0000000..c007707 --- /dev/null +++ b/frontend/src/pages/Auth/ForgotPassword.js @@ -0,0 +1,103 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { useAuth } from '../../hooks/useAuth'; +import LoadingSpinner from '../../components/UI/LoadingSpinner'; + +const ForgotPassword = () => { + const { forgotPassword, loading } = useAuth(); + + const { + register, + handleSubmit, + formState: { errors }, + setError, + reset, + } = useForm(); + + const onSubmit = async (data) => { + const result = await forgotPassword(data.email); + if (result.success) { + reset(); + } else { + setError('root', { + type: 'manual', + message: result.error + }); + } + }; + + return ( +
+
+

Forgot your password?

+

+ Enter your email address and we'll send you a link to reset your password. +

+
+ +
+ {errors.root && ( +
+
+
+

+ {errors.root.message} +

+
+
+
+ )} + +
+ +
+ + {errors.email && ( +

{errors.email.message}

+ )} +
+
+ +
+ +
+ +
+ + Back to sign in + +
+
+
+ ); +}; + +export default ForgotPassword; \ No newline at end of file diff --git a/frontend/src/pages/Auth/Login.js b/frontend/src/pages/Auth/Login.js new file mode 100644 index 0000000..50ae365 --- /dev/null +++ b/frontend/src/pages/Auth/Login.js @@ -0,0 +1,203 @@ +import React, { useState } from 'react'; +import { Link, useNavigate, useLocation } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'; +import { useAuth } from '../../hooks/useAuth'; +import LoadingSpinner from '../../components/UI/LoadingSpinner'; + +const Login = () => { + const [showPassword, setShowPassword] = useState(false); + const { login, loading } = useAuth(); + const navigate = useNavigate(); + const location = useLocation(); + + const from = location.state?.from?.pathname || '/dashboard'; + + const { + register, + handleSubmit, + formState: { errors }, + setError, + } = useForm(); + + const onSubmit = async (data) => { + const result = await login(data); + if (result.success) { + navigate(from, { replace: true }); + } else { + setError('root', { + type: 'manual', + message: result.error + }); + } + }; + + const handleGoogleLogin = () => { + // Redirect to Google OAuth endpoint + window.location.href = `${process.env.REACT_APP_API_URL || 'http://localhost:5000'}/api/auth/google`; + }; + + return ( +
+
+

Sign in to your account

+

+ Or{' '} + + create a new account + +

+
+ +
+ {errors.root && ( +
+
+
+

+ {errors.root.message} +

+
+
+
+ )} + +
+ +
+ + {errors.email && ( +

{errors.email.message}

+ )} +
+
+ +
+ +
+ + + {errors.password && ( +

{errors.password.message}

+ )} +
+
+ +
+
+ + +
+ +
+ + Forgot your password? + +
+
+ +
+ +
+ +
+
+
+
+
+
+ Or continue with +
+
+ +
+ +
+
+ +
+ ); +}; + +export default Login; \ No newline at end of file diff --git a/frontend/src/pages/Auth/Register.js b/frontend/src/pages/Auth/Register.js new file mode 100644 index 0000000..9f6b2f7 --- /dev/null +++ b/frontend/src/pages/Auth/Register.js @@ -0,0 +1,248 @@ +import React, { useState } from 'react'; +import { Link, useNavigate } from 'react-router-dom'; +import { useForm } from 'react-hook-form'; +import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'; +import { useAuth } from '../../hooks/useAuth'; +import LoadingSpinner from '../../components/UI/LoadingSpinner'; + +const Register = () => { + const [showPassword, setShowPassword] = useState(false); + const { register: registerUser, loading } = useAuth(); + const navigate = useNavigate(); + + const { + register, + handleSubmit, + formState: { errors }, + watch, + setError, + } = useForm(); + + const password = watch('password'); + + const onSubmit = async (data) => { + const result = await registerUser(data); + if (result.success) { + navigate('/dashboard'); + } else { + setError('root', { + type: 'manual', + message: result.error + }); + } + }; + + return ( +
+
+

Create your account

+

+ Already have an account?{' '} + + Sign in here + +

+
+ +
+ {errors.root && ( +
+
+
+

+ {errors.root.message} +

+
+
+
+ )} + +
+
+ +
+ + {errors.firstName && ( +

{errors.firstName.message}

+ )} +
+
+ +
+ +
+ + {errors.lastName && ( +

{errors.lastName.message}

+ )} +
+
+
+ +
+ +
+ + {errors.email && ( +

{errors.email.message}

+ )} +
+
+ +
+ +
+ + + {errors.password && ( +

{errors.password.message}

+ )} +
+
+ +
+ +
+ + value === password || 'Passwords do not match', + })} + /> + {errors.confirmPassword && ( +

{errors.confirmPassword.message}

+ )} +
+
+ +
+ + +
+ {errors.agreeTerms && ( +

{errors.agreeTerms.message}

+ )} + +
+ +
+
+
+ ); +}; + +export default Register; \ No newline at end of file diff --git a/frontend/src/pages/Dashboard/Dashboard.js b/frontend/src/pages/Dashboard/Dashboard.js new file mode 100644 index 0000000..31489c5 --- /dev/null +++ b/frontend/src/pages/Dashboard/Dashboard.js @@ -0,0 +1,309 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { + MapIcon, + WrenchScrewdriverIcon, + BeakerIcon, + CalendarDaysIcon, + ClockIcon, + CloudIcon, + PlusIcon, + ArrowTrendingUpIcon, +} from '@heroicons/react/24/outline'; +import { useAuth } from '../../hooks/useAuth'; + +const Dashboard = () => { + const { user } = useAuth(); + + const quickActions = [ + { + name: 'Add Property', + href: '/properties', + icon: MapIcon, + description: 'Set up a new lawn area', + color: 'bg-blue-500', + }, + { + name: 'Plan Application', + href: '/applications/plan', + icon: CalendarDaysIcon, + description: 'Schedule a treatment', + color: 'bg-green-500', + }, + { + name: 'Add Equipment', + href: '/equipment', + icon: WrenchScrewdriverIcon, + description: 'Register new equipment', + color: 'bg-purple-500', + }, + { + name: 'Log Application', + href: '/applications/log', + icon: ClockIcon, + description: 'Record completed work', + color: 'bg-orange-500', + }, + ]; + + const stats = [ + { + name: 'Properties', + value: '3', + change: '+1', + changeType: 'positive', + icon: MapIcon, + }, + { + name: 'This Month\'s Applications', + value: '12', + change: '+4', + changeType: 'positive', + icon: CalendarDaysIcon, + }, + { + name: 'Equipment Items', + value: '8', + change: '+2', + changeType: 'positive', + icon: WrenchScrewdriverIcon, + }, + { + name: 'Products Used', + value: '15', + change: '+3', + changeType: 'positive', + icon: BeakerIcon, + }, + ]; + + const recentActivity = [ + { + id: 1, + type: 'application', + title: 'Applied fertilizer to Front Yard', + property: 'Main Property', + date: '2 hours ago', + status: 'completed', + }, + { + id: 2, + type: 'plan', + title: 'Scheduled weed control treatment', + property: 'Back Yard', + date: 'Tomorrow at 9:00 AM', + status: 'planned', + }, + { + id: 3, + type: 'equipment', + title: 'Added new broadcast spreader', + property: null, + date: '3 days ago', + status: 'completed', + }, + ]; + + const upcomingTasks = [ + { + id: 1, + title: 'Pre-emergent herbicide application', + property: 'Main Property - Front Yard', + date: 'March 15, 2024', + weather: 'Partly cloudy, 65°F', + priority: 'high', + }, + { + id: 2, + title: 'Spring fertilizer application', + property: 'Side Property - Back Lawn', + date: 'March 20, 2024', + weather: 'Sunny, 72°F', + priority: 'medium', + }, + ]; + + return ( +
+ {/* Header */} +
+

+ Welcome back, {user?.firstName}! +

+

+ Here's what's happening with your lawn care today. +

+
+ + {/* Stats Grid */} +
+ {stats.map((stat) => ( +
+
+
+ +
+
+

{stat.name}

+
+

{stat.value}

+ + {stat.change} + +
+
+
+
+ ))} +
+ +
+ {/* Quick Actions */} +
+
+
+

Quick Actions

+

Get started with common tasks

+
+
+ {quickActions.map((action) => ( + +
+ +
+
+

{action.name}

+

{action.description}

+
+ + + ))} +
+
+
+ + {/* Recent Activity & Upcoming Tasks */} +
+ {/* Recent Activity */} +
+
+

Recent Activity

+ + View all + +
+
+ {recentActivity.map((activity) => ( +
+
+
+
+
+

{activity.title}

+ {activity.property && ( +

{activity.property}

+ )} +

{activity.date}

+
+
+ ))} +
+
+ + {/* Upcoming Tasks */} +
+
+

Upcoming Tasks

+ + View all + +
+
+ {upcomingTasks.map((task) => ( +
+
+
+

{task.title}

+

{task.property}

+
+ {task.date} +
+ + {task.weather} +
+
+
+ + {task.priority} + +
+
+ ))} +
+
+
+
+ + {/* Weather Widget */} +
+
+
+

Today's Weather

+ + Detailed forecast + +
+
+
+ +
+

72°F

+

Partly cloudy

+
+
+
+ Wind: + 5 mph +
+
+ Humidity: + 45% +
+
+
+

+ ✓ Good conditions for lawn applications today +

+
+
+
+
+ ); +}; + +export default Dashboard; \ No newline at end of file diff --git a/frontend/src/pages/Equipment/Equipment.js b/frontend/src/pages/Equipment/Equipment.js new file mode 100644 index 0000000..bde0968 --- /dev/null +++ b/frontend/src/pages/Equipment/Equipment.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const Equipment = () => { + return ( +
+

Equipment

+
+

Equipment management coming soon...

+
+
+ ); +}; + +export default Equipment; \ No newline at end of file diff --git a/frontend/src/pages/Error/NotFound.js b/frontend/src/pages/Error/NotFound.js new file mode 100644 index 0000000..030c33a --- /dev/null +++ b/frontend/src/pages/Error/NotFound.js @@ -0,0 +1,46 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { HomeIcon } from '@heroicons/react/24/outline'; + +const NotFound = () => { + return ( +
+
+
+

404

+

+ Page not found +

+

+ Sorry, we couldn't find the page you're looking for. +

+
+ +
+ + + Go back home + + + + View Properties + +
+ +
+

+ If you believe this is an error, please contact support. +

+
+
+
+ ); +}; + +export default NotFound; \ No newline at end of file diff --git a/frontend/src/pages/Error/Unauthorized.js b/frontend/src/pages/Error/Unauthorized.js new file mode 100644 index 0000000..bc7ee58 --- /dev/null +++ b/frontend/src/pages/Error/Unauthorized.js @@ -0,0 +1,48 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { ShieldExclamationIcon, HomeIcon } from '@heroicons/react/24/outline'; + +const Unauthorized = () => { + return ( +
+
+
+
+ +
+

+ Access Denied +

+

+ You don't have permission to access this page. Contact your administrator if you believe this is an error. +

+
+ +
+ + + Go back home + + + + View Profile + +
+ +
+

+ Need help? Contact support for assistance. +

+
+
+
+ ); +}; + +export default Unauthorized; \ No newline at end of file diff --git a/frontend/src/pages/History/History.js b/frontend/src/pages/History/History.js new file mode 100644 index 0000000..d79137b --- /dev/null +++ b/frontend/src/pages/History/History.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const History = () => { + return ( +
+

History

+
+

Application history coming soon...

+
+
+ ); +}; + +export default History; \ No newline at end of file diff --git a/frontend/src/pages/Products/Products.js b/frontend/src/pages/Products/Products.js new file mode 100644 index 0000000..8d6d0fc --- /dev/null +++ b/frontend/src/pages/Products/Products.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const Products = () => { + return ( +
+

Products

+
+

Product management coming soon...

+
+
+ ); +}; + +export default Products; \ No newline at end of file diff --git a/frontend/src/pages/Profile/Profile.js b/frontend/src/pages/Profile/Profile.js new file mode 100644 index 0000000..51e7a41 --- /dev/null +++ b/frontend/src/pages/Profile/Profile.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const Profile = () => { + return ( +
+

Profile

+
+

Profile management coming soon...

+
+
+ ); +}; + +export default Profile; \ No newline at end of file diff --git a/frontend/src/pages/Properties/Properties.js b/frontend/src/pages/Properties/Properties.js new file mode 100644 index 0000000..6005d7d --- /dev/null +++ b/frontend/src/pages/Properties/Properties.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const Properties = () => { + return ( +
+

Properties

+
+

Property management coming soon...

+
+
+ ); +}; + +export default Properties; \ No newline at end of file diff --git a/frontend/src/pages/Properties/PropertyDetail.js b/frontend/src/pages/Properties/PropertyDetail.js new file mode 100644 index 0000000..4c53f96 --- /dev/null +++ b/frontend/src/pages/Properties/PropertyDetail.js @@ -0,0 +1,17 @@ +import React from 'react'; +import { useParams } from 'react-router-dom'; + +const PropertyDetail = () => { + const { id } = useParams(); + + return ( +
+

Property Details

+
+

Property {id} details coming soon...

+
+
+ ); +}; + +export default PropertyDetail; \ No newline at end of file diff --git a/frontend/src/pages/Weather/Weather.js b/frontend/src/pages/Weather/Weather.js new file mode 100644 index 0000000..f9d7dad --- /dev/null +++ b/frontend/src/pages/Weather/Weather.js @@ -0,0 +1,14 @@ +import React from 'react'; + +const Weather = () => { + return ( +
+

Weather

+
+

Weather information coming soon...

+
+
+ ); +}; + +export default Weather; \ No newline at end of file diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js new file mode 100644 index 0000000..1f259ce --- /dev/null +++ b/frontend/src/services/api.js @@ -0,0 +1,177 @@ +import axios from 'axios'; +import toast from 'react-hot-toast'; + +// Base API configuration +const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api'; + +// Create axios instance +const apiClient = axios.create({ + baseURL: API_BASE_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor to add auth token +apiClient.interceptors.request.use( + (config) => { + const token = localStorage.getItem('authToken'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } +); + +// Response interceptor for error handling +apiClient.interceptors.response.use( + (response) => response, + (error) => { + // Handle specific error codes + if (error.response?.status === 401) { + // Unauthorized - clear token and redirect to login + localStorage.removeItem('authToken'); + window.location.href = '/login'; + } else if (error.response?.status === 403) { + // Forbidden + toast.error('You do not have permission to perform this action'); + } else if (error.response?.status >= 500) { + // Server error + toast.error('Server error. Please try again later.'); + } else if (error.code === 'ECONNABORTED') { + // Timeout + toast.error('Request timeout. Please check your connection.'); + } else if (!error.response) { + // Network error + toast.error('Network error. Please check your connection.'); + } + + return Promise.reject(error); + } +); + +// Auth API endpoints +export const authAPI = { + login: (credentials) => apiClient.post('/auth/login', credentials), + register: (userData) => apiClient.post('/auth/register', userData), + getCurrentUser: () => apiClient.get('/auth/me'), + changePassword: (passwordData) => apiClient.post('/auth/change-password', passwordData), + forgotPassword: (email) => apiClient.post('/auth/forgot-password', { email }), +}; + +// Users API endpoints +export const usersAPI = { + getProfile: () => apiClient.get('/users/profile'), + updateProfile: (userData) => apiClient.put('/users/profile', userData), + deleteAccount: () => apiClient.delete('/users/account'), + getStats: () => apiClient.get('/users/stats'), +}; + +// Properties API endpoints +export const propertiesAPI = { + getAll: () => apiClient.get('/properties'), + getById: (id) => apiClient.get(`/properties/${id}`), + create: (propertyData) => apiClient.post('/properties', propertyData), + update: (id, propertyData) => apiClient.put(`/properties/${id}`, propertyData), + delete: (id) => apiClient.delete(`/properties/${id}`), + + // Lawn sections + createSection: (propertyId, sectionData) => + apiClient.post(`/properties/${propertyId}/sections`, sectionData), + updateSection: (propertyId, sectionId, sectionData) => + apiClient.put(`/properties/${propertyId}/sections/${sectionId}`, sectionData), + deleteSection: (propertyId, sectionId) => + apiClient.delete(`/properties/${propertyId}/sections/${sectionId}`), +}; + +// Equipment API endpoints +export const equipmentAPI = { + getAll: () => apiClient.get('/equipment'), + getById: (id) => apiClient.get(`/equipment/${id}`), + create: (equipmentData) => apiClient.post('/equipment', equipmentData), + update: (id, equipmentData) => apiClient.put(`/equipment/${id}`, equipmentData), + delete: (id) => apiClient.delete(`/equipment/${id}`), + getTypes: () => apiClient.get('/equipment/types'), + getCalculations: (id, params) => apiClient.get(`/equipment/${id}/calculations`, { params }), +}; + +// Products API endpoints +export const productsAPI = { + getAll: (params) => apiClient.get('/products', { params }), + getById: (id) => apiClient.get(`/products/${id}`), + search: (params) => apiClient.get('/products/search', { params }), + getCategories: () => apiClient.get('/products/categories'), + + // User products + getUserProducts: () => apiClient.get('/products/user'), + createUserProduct: (productData) => apiClient.post('/products/user', productData), + getUserProduct: (id) => apiClient.get(`/products/user/${id}`), + updateUserProduct: (id, productData) => apiClient.put(`/products/user/${id}`, productData), + deleteUserProduct: (id) => apiClient.delete(`/products/user/${id}`), +}; + +// Applications API endpoints +export const applicationsAPI = { + // Plans + getPlans: (params) => apiClient.get('/applications/plans', { params }), + getPlan: (id) => apiClient.get(`/applications/plans/${id}`), + createPlan: (planData) => apiClient.post('/applications/plans', planData), + updatePlanStatus: (id, status) => apiClient.put(`/applications/plans/${id}/status`, { status }), + + // Logs + getLogs: (params) => apiClient.get('/applications/logs', { params }), + createLog: (logData) => apiClient.post('/applications/logs', logData), + + // Stats + getStats: (params) => apiClient.get('/applications/stats', { params }), +}; + +// Weather API endpoints +export const weatherAPI = { + getCurrent: (propertyId) => apiClient.get(`/weather/${propertyId}`), + getForecast: (propertyId) => apiClient.get(`/weather/${propertyId}/forecast`), + getHistory: (propertyId, params) => apiClient.get(`/weather/${propertyId}/history`, { params }), + checkSuitability: (propertyId, params) => + apiClient.get(`/weather/conditions/suitable/${propertyId}`, { params }), +}; + +// Admin API endpoints +export const adminAPI = { + getDashboard: () => apiClient.get('/admin/dashboard'), + + // Users management + getUsers: (params) => apiClient.get('/admin/users', { params }), + updateUserRole: (id, role) => apiClient.put(`/admin/users/${id}/role`, { role }), + deleteUser: (id) => apiClient.delete(`/admin/users/${id}`), + + // Products management + getProducts: (params) => apiClient.get('/admin/products', { params }), + createProduct: (productData) => apiClient.post('/admin/products', productData), + updateProduct: (id, productData) => apiClient.put(`/admin/products/${id}`, productData), + deleteProduct: (id) => apiClient.delete(`/admin/products/${id}`), + + // System health + getSystemHealth: () => apiClient.get('/admin/system/health'), +}; + +// Utility functions +export const handleApiError = (error, defaultMessage = 'An error occurred') => { + if (error.response?.data?.message) { + return error.response.data.message; + } + if (error.message) { + return error.message; + } + return defaultMessage; +}; + +export const formatApiResponse = (response) => { + return response.data; +}; + +// Export the configured axios instance for custom requests +export default apiClient; \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..49f6449 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,76 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: [ + "./src/**/*.{js,jsx,ts,tsx}", + ], + theme: { + extend: { + fontFamily: { + sans: ['Inter', 'system-ui', 'sans-serif'], + }, + colors: { + primary: { + 50: '#ecfdf5', + 100: '#d1fae5', + 200: '#a7f3d0', + 300: '#6ee7b7', + 400: '#34d399', + 500: '#10b981', + 600: '#059669', + 700: '#047857', + 800: '#065f46', + 900: '#064e3b', + }, + grass: { + 50: '#f0fdf4', + 100: '#dcfce7', + 200: '#bbf7d0', + 300: '#86efac', + 400: '#4ade80', + 500: '#22c55e', + 600: '#16a34a', + 700: '#15803d', + 800: '#166534', + 900: '#14532d', + } + }, + animation: { + 'fade-in': 'fadeIn 0.5s ease-in-out', + 'slide-up': 'slideUp 0.3s ease-out', + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + }, + keyframes: { + fadeIn: { + '0%': { opacity: '0' }, + '100%': { opacity: '1' }, + }, + slideUp: { + '0%': { transform: 'translateY(10px)', opacity: '0' }, + '100%': { transform: 'translateY(0)', opacity: '1' }, + }, + }, + screens: { + 'xs': '475px', + }, + spacing: { + '18': '4.5rem', + '88': '22rem', + '100': '25rem', + '112': '28rem', + '128': '32rem', + }, + zIndex: { + '60': '60', + '70': '70', + '80': '80', + '90': '90', + '100': '100', + } + }, + }, + plugins: [ + require('@tailwindcss/forms'), + require('@tailwindcss/typography'), + require('@tailwindcss/aspect-ratio'), + ], +} \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..fa71d18 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,151 @@ +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Logging + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log warn; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types + application/atom+xml + application/geo+json + application/javascript + application/x-javascript + application/json + application/ld+json + application/manifest+json + application/rdf+xml + application/rss+xml + application/xhtml+xml + application/xml + font/eot + font/otf + font/ttf + image/svg+xml + text/css + text/javascript + text/plain + text/xml; + + # Rate limiting + limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m; + + # Security headers + add_header X-Content-Type-Options nosniff; + add_header X-Frame-Options DENY; + add_header X-XSS-Protection "1; mode=block"; + add_header Referrer-Policy "strict-origin-when-cross-origin"; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://maps.googleapis.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://maps.googleapis.com https://maps.gstatic.com; connect-src 'self' https://api.openweathermap.org;"; + + upstream backend { + server backend:5000; + } + + upstream frontend { + server frontend:3000; + } + + server { + listen 80; + server_name localhost; + + # API routes + location /api/ { + limit_req zone=api burst=20 nodelay; + + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # CORS headers for API + add_header Access-Control-Allow-Origin $http_origin; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; + add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization"; + add_header Access-Control-Allow-Credentials true; + + # Handle preflight requests + if ($request_method = 'OPTIONS') { + add_header Access-Control-Allow-Origin $http_origin; + add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"; + add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept, Authorization"; + add_header Access-Control-Allow-Credentials true; + add_header Content-Length 0; + add_header Content-Type text/plain; + return 204; + } + } + + # Auth routes with stricter rate limiting + location /api/auth/ { + limit_req zone=auth burst=5 nodelay; + + proxy_pass http://backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Health check endpoint + location /health { + proxy_pass http://backend; + access_log off; + } + + # Static files and React app + location / { + proxy_pass http://frontend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Handle React Router + try_files $uri $uri/ @fallback; + } + + # Fallback for React Router (SPA) + location @fallback { + proxy_pass http://frontend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + proxy_pass http://frontend; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Security - deny access to sensitive files + location ~ /\. { + deny all; + access_log off; + log_not_found off; + } + + location ~ ~$ { + deny all; + access_log off; + log_not_found off; + } + } +} \ No newline at end of file