Initial Claude Run
This commit is contained in:
27
.env.example
Normal file
27
.env.example
Normal file
@@ -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
|
||||
299
README.md
299
README.md
@@ -1,3 +1,298 @@
|
||||
# turftracker
|
||||
# TurfTracker - Professional Lawn Care Management
|
||||
|
||||
Turf Tracker Web App
|
||||
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 <repository-url>
|
||||
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
|
||||
31
backend/Dockerfile
Normal file
31
backend/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs
|
||||
RUN adduser -S turftracker -u 1001
|
||||
|
||||
# Change ownership of the app directory
|
||||
RUN chown -R turftracker:nodejs /app
|
||||
USER turftracker
|
||||
|
||||
# Expose port
|
||||
EXPOSE 5000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node healthcheck.js
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
28
backend/healthcheck.js
Normal file
28
backend/healthcheck.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const http = require('http');
|
||||
|
||||
const options = {
|
||||
hostname: 'localhost',
|
||||
port: 5000,
|
||||
path: '/health',
|
||||
method: 'GET',
|
||||
timeout: 2000
|
||||
};
|
||||
|
||||
const req = http.request(options, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
process.exit(0);
|
||||
} else {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
req.end();
|
||||
43
backend/package.json
Normal file
43
backend/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "turftracker-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for TurfTracker lawn care management application",
|
||||
"main": "src/app.js",
|
||||
"scripts": {
|
||||
"start": "node src/app.js",
|
||||
"dev": "nodemon src/app.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"pg": "^8.11.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.1.0",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"passport": "^0.7.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"axios": "^1.6.2",
|
||||
"joi": "^17.11.0",
|
||||
"morgan": "^1.10.0",
|
||||
"compression": "^1.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2",
|
||||
"jest": "^29.7.0",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"keywords": [
|
||||
"lawn care",
|
||||
"agriculture",
|
||||
"tracking",
|
||||
"fertilizer",
|
||||
"turf management"
|
||||
],
|
||||
"author": "TurfTracker Team",
|
||||
"license": "MIT"
|
||||
}
|
||||
115
backend/src/app.js
Normal file
115
backend/src/app.js
Normal file
@@ -0,0 +1,115 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
const compression = require('compression');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
require('dotenv').config();
|
||||
|
||||
const authRoutes = require('./routes/auth');
|
||||
const userRoutes = require('./routes/users');
|
||||
const propertyRoutes = require('./routes/properties');
|
||||
const equipmentRoutes = require('./routes/equipment');
|
||||
const productRoutes = require('./routes/products');
|
||||
const applicationRoutes = require('./routes/applications');
|
||||
const weatherRoutes = require('./routes/weather');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
|
||||
const errorHandler = require('./middleware/errorHandler');
|
||||
const { authenticateToken } = require('./middleware/auth');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 5000;
|
||||
|
||||
// Trust proxy for rate limiting
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: ["'self'", "'unsafe-inline'", "https://maps.googleapis.com"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
||||
imgSrc: ["'self'", "data:", "https://maps.googleapis.com", "https://maps.gstatic.com"],
|
||||
connectSrc: ["'self'", "https://api.openweathermap.org"]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100, // Limit each IP to 100 requests per windowMs
|
||||
message: 'Too many requests from this IP, please try again later.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
app.use(limiter);
|
||||
|
||||
// Stricter rate limiting for auth routes
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 5, // Limit each IP to 5 auth requests per windowMs
|
||||
message: 'Too many authentication attempts, please try again later.',
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(compression());
|
||||
app.use(cors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:3000',
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
app.use(morgan('combined'));
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'OK',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
});
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authLimiter, authRoutes);
|
||||
app.use('/api/users', authenticateToken, userRoutes);
|
||||
app.use('/api/properties', authenticateToken, propertyRoutes);
|
||||
app.use('/api/equipment', authenticateToken, equipmentRoutes);
|
||||
app.use('/api/products', authenticateToken, productRoutes);
|
||||
app.use('/api/applications', authenticateToken, applicationRoutes);
|
||||
app.use('/api/weather', authenticateToken, weatherRoutes);
|
||||
app.use('/api/admin', authenticateToken, adminRoutes);
|
||||
|
||||
// 404 handler
|
||||
app.use('*', (req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
message: 'API endpoint not found'
|
||||
});
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
app.use(errorHandler);
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('SIGTERM received, shutting down gracefully');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
console.log('SIGINT received, shutting down gracefully');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`TurfTracker API server running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
});
|
||||
38
backend/src/config/database.js
Normal file
38
backend/src/config/database.js
Normal file
@@ -0,0 +1,38 @@
|
||||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: process.env.DATABASE_URL,
|
||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 2000,
|
||||
});
|
||||
|
||||
// Test the connection
|
||||
pool.on('connect', () => {
|
||||
console.log('Connected to PostgreSQL database');
|
||||
});
|
||||
|
||||
pool.on('error', (err) => {
|
||||
console.error('Database connection error:', err);
|
||||
process.exit(-1);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('Closing database connections...');
|
||||
pool.end(() => {
|
||||
console.log('Database connections closed.');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
console.log('Closing database connections...');
|
||||
pool.end(() => {
|
||||
console.log('Database connections closed.');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = pool;
|
||||
75
backend/src/middleware/auth.js
Normal file
75
backend/src/middleware/auth.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const pool = require('../config/database');
|
||||
|
||||
const authenticateToken = async (req, res, next) => {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Access token required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
|
||||
// Verify user still exists and is active
|
||||
const userResult = await pool.query(
|
||||
'SELECT id, email, role FROM users WHERE id = $1',
|
||||
[decoded.userId]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Invalid token - user not found'
|
||||
});
|
||||
}
|
||||
|
||||
req.user = userResult.rows[0];
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Invalid or expired token'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const requireAdmin = (req, res, next) => {
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Admin access required'
|
||||
});
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
const requireOwnership = (resourceUserIdField = 'user_id') => {
|
||||
return (req, res, next) => {
|
||||
const resourceUserId = req.params[resourceUserIdField] || req.body[resourceUserIdField];
|
||||
|
||||
if (req.user.role === 'admin') {
|
||||
return next(); // Admins can access any resource
|
||||
}
|
||||
|
||||
if (parseInt(resourceUserId) !== req.user.id) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Access denied - you can only access your own resources'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
requireAdmin,
|
||||
requireOwnership
|
||||
};
|
||||
97
backend/src/middleware/errorHandler.js
Normal file
97
backend/src/middleware/errorHandler.js
Normal file
@@ -0,0 +1,97 @@
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
console.error('Error occurred:', {
|
||||
message: err.message,
|
||||
stack: err.stack,
|
||||
url: req.url,
|
||||
method: req.method,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('User-Agent')
|
||||
});
|
||||
|
||||
// Default error
|
||||
let error = {
|
||||
success: false,
|
||||
message: 'Internal server error'
|
||||
};
|
||||
|
||||
// Validation error
|
||||
if (err.isJoi) {
|
||||
error.message = 'Validation error';
|
||||
error.details = err.details.map(detail => detail.message);
|
||||
return res.status(400).json(error);
|
||||
}
|
||||
|
||||
// JWT errors
|
||||
if (err.name === 'JsonWebTokenError') {
|
||||
error.message = 'Invalid token';
|
||||
return res.status(401).json(error);
|
||||
}
|
||||
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
error.message = 'Token expired';
|
||||
return res.status(401).json(error);
|
||||
}
|
||||
|
||||
// PostgreSQL errors
|
||||
if (err.code) {
|
||||
switch (err.code) {
|
||||
case '23505': // Unique violation
|
||||
error.message = 'Duplicate entry - resource already exists';
|
||||
return res.status(409).json(error);
|
||||
|
||||
case '23503': // Foreign key violation
|
||||
error.message = 'Invalid reference - related resource not found';
|
||||
return res.status(400).json(error);
|
||||
|
||||
case '23502': // Not null violation
|
||||
error.message = 'Missing required field';
|
||||
return res.status(400).json(error);
|
||||
|
||||
case '42P01': // Undefined table
|
||||
error.message = 'Database configuration error';
|
||||
return res.status(500).json(error);
|
||||
|
||||
default:
|
||||
error.message = 'Database error occurred';
|
||||
return res.status(500).json(error);
|
||||
}
|
||||
}
|
||||
|
||||
// Custom application errors
|
||||
if (err.statusCode) {
|
||||
error.message = err.message || 'Application error';
|
||||
return res.status(err.statusCode).json(error);
|
||||
}
|
||||
|
||||
// File upload errors
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
error.message = 'File too large';
|
||||
return res.status(413).json(error);
|
||||
}
|
||||
|
||||
if (err.code === 'LIMIT_UNEXPECTED_FILE') {
|
||||
error.message = 'Unexpected file field';
|
||||
return res.status(400).json(error);
|
||||
}
|
||||
|
||||
// Network/timeout errors
|
||||
if (err.code === 'ECONNREFUSED' || err.code === 'ETIMEDOUT') {
|
||||
error.message = 'External service unavailable';
|
||||
return res.status(503).json(error);
|
||||
}
|
||||
|
||||
// Default 500 error
|
||||
res.status(500).json(error);
|
||||
};
|
||||
|
||||
class AppError extends Error {
|
||||
constructor(message, statusCode) {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.isOperational = true;
|
||||
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { errorHandler, AppError };
|
||||
529
backend/src/routes/admin.js
Normal file
529
backend/src/routes/admin.js
Normal file
@@ -0,0 +1,529 @@
|
||||
const express = require('express');
|
||||
const pool = require('../config/database');
|
||||
const { validateRequest, validateParams } = require('../utils/validation');
|
||||
const { productSchema, idParamSchema } = require('../utils/validation');
|
||||
const { requireAdmin } = require('../middleware/auth');
|
||||
const { AppError } = require('../middleware/errorHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Apply admin middleware to all routes
|
||||
router.use(requireAdmin);
|
||||
|
||||
// @route GET /api/admin/dashboard
|
||||
// @desc Get admin dashboard statistics
|
||||
// @access Private (Admin)
|
||||
router.get('/dashboard', async (req, res, next) => {
|
||||
try {
|
||||
const statsQuery = `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM users) as total_users,
|
||||
(SELECT COUNT(*) FROM users WHERE role = 'admin') as admin_users,
|
||||
(SELECT COUNT(*) FROM users WHERE created_at >= CURRENT_DATE - INTERVAL '30 days') as new_users_30d,
|
||||
(SELECT COUNT(*) FROM properties) as total_properties,
|
||||
(SELECT COUNT(*) FROM user_equipment) as total_equipment,
|
||||
(SELECT COUNT(*) FROM products) as total_products,
|
||||
(SELECT COUNT(*) FROM user_products) as custom_products,
|
||||
(SELECT COUNT(*) FROM application_plans) as total_plans,
|
||||
(SELECT COUNT(*) FROM application_logs) as total_applications,
|
||||
(SELECT COUNT(*) FROM application_logs WHERE application_date >= CURRENT_DATE - INTERVAL '7 days') as recent_applications
|
||||
`;
|
||||
|
||||
const statsResult = await pool.query(statsQuery);
|
||||
const stats = statsResult.rows[0];
|
||||
|
||||
// Get user activity (users with recent activity)
|
||||
const userActivityQuery = `
|
||||
SELECT
|
||||
DATE_TRUNC('day', created_at) as date,
|
||||
COUNT(*) as new_registrations
|
||||
FROM users
|
||||
WHERE created_at >= CURRENT_DATE - INTERVAL '30 days'
|
||||
GROUP BY DATE_TRUNC('day', created_at)
|
||||
ORDER BY date DESC
|
||||
`;
|
||||
|
||||
const activityResult = await pool.query(userActivityQuery);
|
||||
|
||||
// Get application activity
|
||||
const applicationActivityQuery = `
|
||||
SELECT
|
||||
DATE_TRUNC('day', application_date) as date,
|
||||
COUNT(*) as applications,
|
||||
COUNT(DISTINCT user_id) as active_users
|
||||
FROM application_logs
|
||||
WHERE application_date >= CURRENT_DATE - INTERVAL '30 days'
|
||||
GROUP BY DATE_TRUNC('day', application_date)
|
||||
ORDER BY date DESC
|
||||
`;
|
||||
|
||||
const appActivityResult = await pool.query(applicationActivityQuery);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
stats: {
|
||||
totalUsers: parseInt(stats.total_users),
|
||||
adminUsers: parseInt(stats.admin_users),
|
||||
newUsers30d: parseInt(stats.new_users_30d),
|
||||
totalProperties: parseInt(stats.total_properties),
|
||||
totalEquipment: parseInt(stats.total_equipment),
|
||||
totalProducts: parseInt(stats.total_products),
|
||||
customProducts: parseInt(stats.custom_products),
|
||||
totalPlans: parseInt(stats.total_plans),
|
||||
totalApplications: parseInt(stats.total_applications),
|
||||
recentApplications: parseInt(stats.recent_applications)
|
||||
},
|
||||
userActivity: activityResult.rows.map(row => ({
|
||||
date: row.date,
|
||||
newRegistrations: parseInt(row.new_registrations)
|
||||
})),
|
||||
applicationActivity: appActivityResult.rows.map(row => ({
|
||||
date: row.date,
|
||||
applications: parseInt(row.applications),
|
||||
activeUsers: parseInt(row.active_users)
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/admin/users
|
||||
// @desc Get all users with pagination
|
||||
// @access Private (Admin)
|
||||
router.get('/users', async (req, res, next) => {
|
||||
try {
|
||||
const { page = 1, limit = 20, search, role } = req.query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
let whereConditions = [];
|
||||
let queryParams = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (search) {
|
||||
paramCount++;
|
||||
whereConditions.push(`(first_name ILIKE $${paramCount} OR last_name ILIKE $${paramCount} OR email ILIKE $${paramCount})`);
|
||||
queryParams.push(`%${search}%`);
|
||||
}
|
||||
|
||||
if (role) {
|
||||
paramCount++;
|
||||
whereConditions.push(`role = $${paramCount}`);
|
||||
queryParams.push(role);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
|
||||
|
||||
// Get total count
|
||||
const countQuery = `SELECT COUNT(*) as total FROM users ${whereClause}`;
|
||||
const countResult = await pool.query(countQuery, queryParams);
|
||||
const totalUsers = parseInt(countResult.rows[0].total);
|
||||
|
||||
// Get users with stats
|
||||
paramCount++;
|
||||
queryParams.push(limit);
|
||||
paramCount++;
|
||||
queryParams.push(offset);
|
||||
|
||||
const usersQuery = `
|
||||
SELECT u.id, u.email, u.first_name, u.last_name, u.role, u.oauth_provider,
|
||||
u.created_at, u.updated_at,
|
||||
COUNT(DISTINCT p.id) as property_count,
|
||||
COUNT(DISTINCT ap.id) as application_count,
|
||||
MAX(al.application_date) as last_application
|
||||
FROM users u
|
||||
LEFT JOIN properties p ON u.id = p.user_id
|
||||
LEFT JOIN application_plans ap ON u.id = ap.user_id
|
||||
LEFT JOIN application_logs al ON u.id = al.user_id
|
||||
${whereClause}
|
||||
GROUP BY u.id
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT $${paramCount - 1} OFFSET $${paramCount}
|
||||
`;
|
||||
|
||||
const usersResult = await pool.query(usersQuery, queryParams);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
users: usersResult.rows.map(user => ({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
role: user.role,
|
||||
oauthProvider: user.oauth_provider,
|
||||
propertyCount: parseInt(user.property_count),
|
||||
applicationCount: parseInt(user.application_count),
|
||||
lastApplication: user.last_application,
|
||||
createdAt: user.created_at,
|
||||
updatedAt: user.updated_at
|
||||
})),
|
||||
pagination: {
|
||||
currentPage: parseInt(page),
|
||||
totalPages: Math.ceil(totalUsers / limit),
|
||||
totalUsers,
|
||||
hasNext: (page * limit) < totalUsers,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/admin/users/:id/role
|
||||
// @desc Update user role
|
||||
// @access Private (Admin)
|
||||
router.put('/users/:id/role', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.params.id;
|
||||
const { role } = req.body;
|
||||
|
||||
if (!['admin', 'user'].includes(role)) {
|
||||
throw new AppError('Invalid role', 400);
|
||||
}
|
||||
|
||||
// Prevent removing admin role from yourself
|
||||
if (parseInt(userId) === req.user.id && role !== 'admin') {
|
||||
throw new AppError('Cannot remove admin role from yourself', 400);
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const userCheck = await pool.query(
|
||||
'SELECT id, role FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.rows.length === 0) {
|
||||
throw new AppError('User not found', 404);
|
||||
}
|
||||
|
||||
// Update role
|
||||
const result = await pool.query(
|
||||
'UPDATE users SET role = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING id, email, role',
|
||||
[role, userId]
|
||||
);
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'User role updated successfully',
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/admin/users/:id
|
||||
// @desc Delete user account
|
||||
// @access Private (Admin)
|
||||
router.delete('/users/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.params.id;
|
||||
|
||||
// Prevent deleting yourself
|
||||
if (parseInt(userId) === req.user.id) {
|
||||
throw new AppError('Cannot delete your own account', 400);
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const userCheck = await pool.query(
|
||||
'SELECT id, email FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (userCheck.rows.length === 0) {
|
||||
throw new AppError('User not found', 404);
|
||||
}
|
||||
|
||||
const user = userCheck.rows[0];
|
||||
|
||||
// Delete user (cascading will handle related records)
|
||||
await pool.query('DELETE FROM users WHERE id = $1', [userId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `User ${user.email} deleted successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/admin/products
|
||||
// @desc Get all products for management
|
||||
// @access Private (Admin)
|
||||
router.get('/products', async (req, res, next) => {
|
||||
try {
|
||||
const { category, search } = req.query;
|
||||
|
||||
let whereConditions = [];
|
||||
let queryParams = [];
|
||||
let paramCount = 0;
|
||||
|
||||
if (category) {
|
||||
paramCount++;
|
||||
whereConditions.push(`p.category_id = $${paramCount}`);
|
||||
queryParams.push(category);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
paramCount++;
|
||||
whereConditions.push(`(p.name ILIKE $${paramCount} OR p.brand ILIKE $${paramCount})`);
|
||||
queryParams.push(`%${search}%`);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(' AND ')}` : '';
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT p.*, pc.name as category_name,
|
||||
COUNT(pr.id) as rate_count,
|
||||
COUNT(up.id) as usage_count
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
LEFT JOIN product_rates pr ON p.id = pr.product_id
|
||||
LEFT JOIN user_products up ON p.id = up.product_id
|
||||
${whereClause}
|
||||
GROUP BY p.id, pc.name
|
||||
ORDER BY p.name`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
products: result.rows.map(product => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
categoryName: product.category_name,
|
||||
productType: product.product_type,
|
||||
activeIngredients: product.active_ingredients,
|
||||
description: product.description,
|
||||
rateCount: parseInt(product.rate_count),
|
||||
usageCount: parseInt(product.usage_count),
|
||||
createdAt: product.created_at
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/admin/products
|
||||
// @desc Create new product
|
||||
// @access Private (Admin)
|
||||
router.post('/products', validateRequest(productSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { name, brand, categoryId, productType, activeIngredients, description } = req.body;
|
||||
|
||||
// Check if category exists
|
||||
const categoryCheck = await pool.query(
|
||||
'SELECT id FROM product_categories WHERE id = $1',
|
||||
[categoryId]
|
||||
);
|
||||
|
||||
if (categoryCheck.rows.length === 0) {
|
||||
throw new AppError('Product category not found', 404);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO products (name, brand, category_id, product_type, active_ingredients, description)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[name, brand, categoryId, productType, activeIngredients, description]
|
||||
);
|
||||
|
||||
const product = result.rows[0];
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Product created successfully',
|
||||
data: {
|
||||
product: {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
categoryId: product.category_id,
|
||||
productType: product.product_type,
|
||||
activeIngredients: product.active_ingredients,
|
||||
description: product.description,
|
||||
createdAt: product.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/admin/products/:id
|
||||
// @desc Update product
|
||||
// @access Private (Admin)
|
||||
router.put('/products/:id', validateParams(idParamSchema), validateRequest(productSchema), async (req, res, next) => {
|
||||
try {
|
||||
const productId = req.params.id;
|
||||
const { name, brand, categoryId, productType, activeIngredients, description } = req.body;
|
||||
|
||||
// Check if product exists
|
||||
const productCheck = await pool.query(
|
||||
'SELECT id FROM products WHERE id = $1',
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (productCheck.rows.length === 0) {
|
||||
throw new AppError('Product not found', 404);
|
||||
}
|
||||
|
||||
// Check if category exists
|
||||
const categoryCheck = await pool.query(
|
||||
'SELECT id FROM product_categories WHERE id = $1',
|
||||
[categoryId]
|
||||
);
|
||||
|
||||
if (categoryCheck.rows.length === 0) {
|
||||
throw new AppError('Product category not found', 404);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE products
|
||||
SET name = $1, brand = $2, category_id = $3, product_type = $4,
|
||||
active_ingredients = $5, description = $6
|
||||
WHERE id = $7
|
||||
RETURNING *`,
|
||||
[name, brand, categoryId, productType, activeIngredients, description, productId]
|
||||
);
|
||||
|
||||
const product = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Product updated successfully',
|
||||
data: {
|
||||
product: {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
categoryId: product.category_id,
|
||||
productType: product.product_type,
|
||||
activeIngredients: product.active_ingredients,
|
||||
description: product.description,
|
||||
createdAt: product.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/admin/products/:id
|
||||
// @desc Delete product
|
||||
// @access Private (Admin)
|
||||
router.delete('/products/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const productId = req.params.id;
|
||||
|
||||
// Check if product exists
|
||||
const productCheck = await pool.query(
|
||||
'SELECT id, name FROM products WHERE id = $1',
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (productCheck.rows.length === 0) {
|
||||
throw new AppError('Product not found', 404);
|
||||
}
|
||||
|
||||
const product = productCheck.rows[0];
|
||||
|
||||
// Check if product is used in any user products or applications
|
||||
const usageCheck = await pool.query(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM user_products WHERE product_id = $1) +
|
||||
(SELECT COUNT(*) FROM application_plan_products WHERE product_id = $1) +
|
||||
(SELECT COUNT(*) FROM application_log_products WHERE product_id = $1) as usage_count`,
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (parseInt(usageCheck.rows[0].usage_count) > 0) {
|
||||
throw new AppError('Cannot delete product that is being used by users', 400);
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM products WHERE id = $1', [productId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Product "${product.name}" deleted successfully`
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/admin/system/health
|
||||
// @desc Get system health information
|
||||
// @access Private (Admin)
|
||||
router.get('/system/health', async (req, res, next) => {
|
||||
try {
|
||||
// Database connection test
|
||||
const dbResult = await pool.query('SELECT NOW() as timestamp, version() as version');
|
||||
|
||||
// Get database statistics
|
||||
const dbStats = await pool.query(`
|
||||
SELECT
|
||||
pg_size_pretty(pg_database_size(current_database())) as database_size,
|
||||
(SELECT count(*) FROM pg_stat_activity WHERE state = 'active') as active_connections,
|
||||
(SELECT setting FROM pg_settings WHERE name = 'max_connections') as max_connections
|
||||
`);
|
||||
|
||||
const stats = dbStats.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
system: {
|
||||
status: 'healthy',
|
||||
timestamp: new Date(),
|
||||
uptime: process.uptime(),
|
||||
nodeVersion: process.version,
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
},
|
||||
database: {
|
||||
status: 'connected',
|
||||
version: dbResult.rows[0].version,
|
||||
size: stats.database_size,
|
||||
activeConnections: parseInt(stats.active_connections),
|
||||
maxConnections: parseInt(stats.max_connections),
|
||||
timestamp: dbResult.rows[0].timestamp
|
||||
},
|
||||
services: {
|
||||
weatherApi: {
|
||||
configured: !!process.env.WEATHER_API_KEY,
|
||||
status: process.env.WEATHER_API_KEY ? 'available' : 'not configured'
|
||||
},
|
||||
googleOAuth: {
|
||||
configured: !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET),
|
||||
status: (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) ? 'available' : 'not configured'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
590
backend/src/routes/applications.js
Normal file
590
backend/src/routes/applications.js
Normal file
@@ -0,0 +1,590 @@
|
||||
const express = require('express');
|
||||
const pool = require('../config/database');
|
||||
const { validateRequest, validateParams } = require('../utils/validation');
|
||||
const { applicationPlanSchema, applicationLogSchema, idParamSchema } = require('../utils/validation');
|
||||
const { AppError } = require('../middleware/errorHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// @route GET /api/applications/plans
|
||||
// @desc Get all application plans for current user
|
||||
// @access Private
|
||||
router.get('/plans', async (req, res, next) => {
|
||||
try {
|
||||
const { status, upcoming, property_id } = req.query;
|
||||
|
||||
let whereConditions = ['ap.user_id = $1'];
|
||||
let queryParams = [req.user.id];
|
||||
let paramCount = 1;
|
||||
|
||||
if (status) {
|
||||
paramCount++;
|
||||
whereConditions.push(`ap.status = $${paramCount}`);
|
||||
queryParams.push(status);
|
||||
}
|
||||
|
||||
if (upcoming === 'true') {
|
||||
paramCount++;
|
||||
whereConditions.push(`ap.planned_date >= $${paramCount}`);
|
||||
queryParams.push(new Date().toISOString().split('T')[0]);
|
||||
}
|
||||
|
||||
if (property_id) {
|
||||
paramCount++;
|
||||
whereConditions.push(`p.id = $${paramCount}`);
|
||||
queryParams.push(property_id);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(' AND ');
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT ap.*, ls.name as section_name, ls.area as section_area,
|
||||
p.name as property_name, p.address as property_address,
|
||||
ue.custom_name as equipment_name, et.name as equipment_type,
|
||||
COUNT(app.id) as product_count
|
||||
FROM application_plans ap
|
||||
JOIN lawn_sections ls ON ap.lawn_section_id = ls.id
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
LEFT JOIN user_equipment ue ON ap.equipment_id = ue.id
|
||||
LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id
|
||||
LEFT JOIN application_plan_products app ON ap.id = app.plan_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY ap.id, ls.name, ls.area, p.name, p.address, ue.custom_name, et.name
|
||||
ORDER BY ap.planned_date DESC, ap.created_at DESC`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
plans: result.rows.map(plan => ({
|
||||
id: plan.id,
|
||||
status: plan.status,
|
||||
plannedDate: plan.planned_date,
|
||||
notes: plan.notes,
|
||||
sectionName: plan.section_name,
|
||||
sectionArea: parseFloat(plan.section_area),
|
||||
propertyName: plan.property_name,
|
||||
propertyAddress: plan.property_address,
|
||||
equipmentName: plan.equipment_name || plan.equipment_type,
|
||||
productCount: parseInt(plan.product_count),
|
||||
createdAt: plan.created_at,
|
||||
updatedAt: plan.updated_at
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/applications/plans/:id
|
||||
// @desc Get single application plan with products
|
||||
// @access Private
|
||||
router.get('/plans/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const planId = req.params.id;
|
||||
|
||||
// Get plan details
|
||||
const planResult = await pool.query(
|
||||
`SELECT ap.*, ls.name as section_name, ls.area as section_area, ls.polygon_data,
|
||||
p.id as property_id, p.name as property_name, p.address as property_address,
|
||||
ue.id as equipment_id, ue.custom_name as equipment_name,
|
||||
et.name as equipment_type, et.category as equipment_category
|
||||
FROM application_plans ap
|
||||
JOIN lawn_sections ls ON ap.lawn_section_id = ls.id
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
LEFT JOIN user_equipment ue ON ap.equipment_id = ue.id
|
||||
LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id
|
||||
WHERE ap.id = $1 AND ap.user_id = $2`,
|
||||
[planId, req.user.id]
|
||||
);
|
||||
|
||||
if (planResult.rows.length === 0) {
|
||||
throw new AppError('Application plan not found', 404);
|
||||
}
|
||||
|
||||
const plan = planResult.rows[0];
|
||||
|
||||
// Get plan products
|
||||
const productsResult = await pool.query(
|
||||
`SELECT app.*,
|
||||
COALESCE(up.custom_name, p.name) as product_name,
|
||||
COALESCE(p.brand, '') as product_brand,
|
||||
COALESCE(p.product_type, 'unknown') as product_type
|
||||
FROM application_plan_products app
|
||||
LEFT JOIN products p ON app.product_id = p.id
|
||||
LEFT JOIN user_products up ON app.user_product_id = up.id
|
||||
WHERE app.plan_id = $1
|
||||
ORDER BY app.id`,
|
||||
[planId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
plan: {
|
||||
id: plan.id,
|
||||
status: plan.status,
|
||||
plannedDate: plan.planned_date,
|
||||
notes: plan.notes,
|
||||
section: {
|
||||
id: plan.lawn_section_id,
|
||||
name: plan.section_name,
|
||||
area: parseFloat(plan.section_area),
|
||||
polygonData: plan.polygon_data
|
||||
},
|
||||
property: {
|
||||
id: plan.property_id,
|
||||
name: plan.property_name,
|
||||
address: plan.property_address
|
||||
},
|
||||
equipment: {
|
||||
id: plan.equipment_id,
|
||||
name: plan.equipment_name || plan.equipment_type,
|
||||
type: plan.equipment_type,
|
||||
category: plan.equipment_category
|
||||
},
|
||||
products: productsResult.rows.map(product => ({
|
||||
id: product.id,
|
||||
productId: product.product_id,
|
||||
userProductId: product.user_product_id,
|
||||
productName: product.product_name,
|
||||
productBrand: product.product_brand,
|
||||
productType: product.product_type,
|
||||
rateAmount: parseFloat(product.rate_amount),
|
||||
rateUnit: product.rate_unit,
|
||||
calculatedProductAmount: parseFloat(product.calculated_product_amount),
|
||||
calculatedWaterAmount: parseFloat(product.calculated_water_amount),
|
||||
targetSpeedMph: parseFloat(product.target_speed_mph)
|
||||
})),
|
||||
createdAt: plan.created_at,
|
||||
updatedAt: plan.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/applications/plans
|
||||
// @desc Create new application plan
|
||||
// @access Private
|
||||
router.post('/plans', validateRequest(applicationPlanSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { lawnSectionId, equipmentId, plannedDate, notes, products } = req.body;
|
||||
|
||||
// Start transaction
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Verify lawn section belongs to user
|
||||
const sectionCheck = await client.query(
|
||||
`SELECT ls.id, ls.area, p.user_id
|
||||
FROM lawn_sections ls
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
WHERE ls.id = $1 AND p.user_id = $2`,
|
||||
[lawnSectionId, req.user.id]
|
||||
);
|
||||
|
||||
if (sectionCheck.rows.length === 0) {
|
||||
throw new AppError('Lawn section not found', 404);
|
||||
}
|
||||
|
||||
const section = sectionCheck.rows[0];
|
||||
|
||||
// Verify equipment belongs to user
|
||||
const equipmentCheck = await client.query(
|
||||
'SELECT id, tank_size, pump_gpm, nozzle_gpm, nozzle_count FROM user_equipment WHERE id = $1 AND user_id = $2',
|
||||
[equipmentId, req.user.id]
|
||||
);
|
||||
|
||||
if (equipmentCheck.rows.length === 0) {
|
||||
throw new AppError('Equipment not found', 404);
|
||||
}
|
||||
|
||||
const equipment = equipmentCheck.rows[0];
|
||||
|
||||
// Create application plan
|
||||
const planResult = await client.query(
|
||||
`INSERT INTO application_plans (user_id, lawn_section_id, equipment_id, planned_date, notes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[req.user.id, lawnSectionId, equipmentId, plannedDate, notes]
|
||||
);
|
||||
|
||||
const plan = planResult.rows[0];
|
||||
|
||||
// Add products to plan with calculations
|
||||
for (const product of products) {
|
||||
const { productId, userProductId, rateAmount, rateUnit } = product;
|
||||
|
||||
// Calculate application amounts based on area and rate
|
||||
const sectionArea = parseFloat(section.area);
|
||||
let calculatedProductAmount = 0;
|
||||
let calculatedWaterAmount = 0;
|
||||
let targetSpeed = 3; // Default 3 MPH
|
||||
|
||||
// Basic calculation logic (can be enhanced based on equipment type)
|
||||
if (rateUnit.includes('1000sqft')) {
|
||||
calculatedProductAmount = rateAmount * (sectionArea / 1000);
|
||||
} else if (rateUnit.includes('acre')) {
|
||||
calculatedProductAmount = rateAmount * (sectionArea / 43560);
|
||||
} else {
|
||||
calculatedProductAmount = rateAmount;
|
||||
}
|
||||
|
||||
// Water calculation for liquid applications
|
||||
if (rateUnit.includes('gal')) {
|
||||
calculatedWaterAmount = calculatedProductAmount;
|
||||
} else if (rateUnit.includes('oz/gal')) {
|
||||
calculatedWaterAmount = sectionArea / 1000; // 1 gal per 1000 sqft default
|
||||
calculatedProductAmount = rateAmount * calculatedWaterAmount;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO application_plan_products
|
||||
(plan_id, product_id, user_product_id, rate_amount, rate_unit,
|
||||
calculated_product_amount, calculated_water_amount, target_speed_mph)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[plan.id, productId, userProductId, rateAmount, rateUnit,
|
||||
calculatedProductAmount, calculatedWaterAmount, targetSpeed]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Application plan created successfully',
|
||||
data: {
|
||||
plan: {
|
||||
id: plan.id,
|
||||
status: plan.status,
|
||||
plannedDate: plan.planned_date,
|
||||
notes: plan.notes,
|
||||
createdAt: plan.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/applications/plans/:id/status
|
||||
// @desc Update application plan status
|
||||
// @access Private
|
||||
router.put('/plans/:id/status', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const planId = req.params.id;
|
||||
const { status } = req.body;
|
||||
|
||||
if (!['planned', 'in_progress', 'completed', 'cancelled'].includes(status)) {
|
||||
throw new AppError('Invalid status', 400);
|
||||
}
|
||||
|
||||
// Check if plan belongs to user
|
||||
const checkResult = await pool.query(
|
||||
'SELECT id, status FROM application_plans WHERE id = $1 AND user_id = $2',
|
||||
[planId, req.user.id]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length === 0) {
|
||||
throw new AppError('Application plan not found', 404);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE application_plans
|
||||
SET status = $1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2
|
||||
RETURNING *`,
|
||||
[status, planId]
|
||||
);
|
||||
|
||||
const plan = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Plan status updated successfully',
|
||||
data: {
|
||||
plan: {
|
||||
id: plan.id,
|
||||
status: plan.status,
|
||||
updatedAt: plan.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/applications/logs
|
||||
// @desc Get application logs for current user
|
||||
// @access Private
|
||||
router.get('/logs', async (req, res, next) => {
|
||||
try {
|
||||
const { property_id, start_date, end_date, limit = 50 } = req.query;
|
||||
|
||||
let whereConditions = ['al.user_id = $1'];
|
||||
let queryParams = [req.user.id];
|
||||
let paramCount = 1;
|
||||
|
||||
if (property_id) {
|
||||
paramCount++;
|
||||
whereConditions.push(`p.id = $${paramCount}`);
|
||||
queryParams.push(property_id);
|
||||
}
|
||||
|
||||
if (start_date) {
|
||||
paramCount++;
|
||||
whereConditions.push(`al.application_date >= $${paramCount}`);
|
||||
queryParams.push(start_date);
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
paramCount++;
|
||||
whereConditions.push(`al.application_date <= $${paramCount}`);
|
||||
queryParams.push(end_date);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(' AND ');
|
||||
|
||||
paramCount++;
|
||||
queryParams.push(limit);
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT al.*, ls.name as section_name, ls.area as section_area,
|
||||
p.name as property_name, p.address as property_address,
|
||||
ue.custom_name as equipment_name, et.name as equipment_type,
|
||||
COUNT(alp.id) as product_count
|
||||
FROM application_logs al
|
||||
JOIN lawn_sections ls ON al.lawn_section_id = ls.id
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
LEFT JOIN user_equipment ue ON al.equipment_id = ue.id
|
||||
LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id
|
||||
LEFT JOIN application_log_products alp ON al.id = alp.log_id
|
||||
WHERE ${whereClause}
|
||||
GROUP BY al.id, ls.name, ls.area, p.name, p.address, ue.custom_name, et.name
|
||||
ORDER BY al.application_date DESC
|
||||
LIMIT $${paramCount}`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
logs: result.rows.map(log => ({
|
||||
id: log.id,
|
||||
planId: log.plan_id,
|
||||
applicationDate: log.application_date,
|
||||
weatherConditions: log.weather_conditions,
|
||||
averageSpeed: parseFloat(log.average_speed),
|
||||
areaCovered: parseFloat(log.area_covered),
|
||||
notes: log.notes,
|
||||
sectionName: log.section_name,
|
||||
sectionArea: parseFloat(log.section_area),
|
||||
propertyName: log.property_name,
|
||||
propertyAddress: log.property_address,
|
||||
equipmentName: log.equipment_name || log.equipment_type,
|
||||
productCount: parseInt(log.product_count),
|
||||
createdAt: log.created_at
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/applications/logs
|
||||
// @desc Create application log
|
||||
// @access Private
|
||||
router.post('/logs', validateRequest(applicationLogSchema), async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
planId,
|
||||
lawnSectionId,
|
||||
equipmentId,
|
||||
weatherConditions,
|
||||
gpsTrack,
|
||||
averageSpeed,
|
||||
areaCovered,
|
||||
notes,
|
||||
products
|
||||
} = req.body;
|
||||
|
||||
// Start transaction
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Verify lawn section belongs to user
|
||||
const sectionCheck = await client.query(
|
||||
`SELECT ls.id, p.user_id
|
||||
FROM lawn_sections ls
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
WHERE ls.id = $1 AND p.user_id = $2`,
|
||||
[lawnSectionId, req.user.id]
|
||||
);
|
||||
|
||||
if (sectionCheck.rows.length === 0) {
|
||||
throw new AppError('Lawn section not found', 404);
|
||||
}
|
||||
|
||||
// Verify equipment belongs to user (if provided)
|
||||
if (equipmentId) {
|
||||
const equipmentCheck = await client.query(
|
||||
'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2',
|
||||
[equipmentId, req.user.id]
|
||||
);
|
||||
|
||||
if (equipmentCheck.rows.length === 0) {
|
||||
throw new AppError('Equipment not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
// Create application log
|
||||
const logResult = await client.query(
|
||||
`INSERT INTO application_logs
|
||||
(plan_id, user_id, lawn_section_id, equipment_id, weather_conditions,
|
||||
gps_track, average_speed, area_covered, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
RETURNING *`,
|
||||
[planId, req.user.id, lawnSectionId, equipmentId,
|
||||
JSON.stringify(weatherConditions), JSON.stringify(gpsTrack),
|
||||
averageSpeed, areaCovered, notes]
|
||||
);
|
||||
|
||||
const log = logResult.rows[0];
|
||||
|
||||
// Add products to log
|
||||
for (const product of products) {
|
||||
const {
|
||||
productId,
|
||||
userProductId,
|
||||
rateAmount,
|
||||
rateUnit,
|
||||
actualProductAmount,
|
||||
actualWaterAmount,
|
||||
actualSpeedMph
|
||||
} = product;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO application_log_products
|
||||
(log_id, product_id, user_product_id, rate_amount, rate_unit,
|
||||
actual_product_amount, actual_water_amount, actual_speed_mph)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||
[log.id, productId, userProductId, rateAmount, rateUnit,
|
||||
actualProductAmount, actualWaterAmount, actualSpeedMph]
|
||||
);
|
||||
}
|
||||
|
||||
// If this was from a plan, mark the plan as completed
|
||||
if (planId) {
|
||||
await client.query(
|
||||
'UPDATE application_plans SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
['completed', planId]
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Application logged successfully',
|
||||
data: {
|
||||
log: {
|
||||
id: log.id,
|
||||
applicationDate: log.application_date,
|
||||
createdAt: log.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/applications/stats
|
||||
// @desc Get application statistics
|
||||
// @access Private
|
||||
router.get('/stats', async (req, res, next) => {
|
||||
try {
|
||||
const { year = new Date().getFullYear() } = req.query;
|
||||
|
||||
const statsQuery = `
|
||||
SELECT
|
||||
COUNT(DISTINCT al.id) as total_applications,
|
||||
COUNT(DISTINCT ap.id) as total_plans,
|
||||
COUNT(DISTINCT CASE WHEN ap.status = 'completed' THEN ap.id END) as completed_plans,
|
||||
COUNT(DISTINCT CASE WHEN ap.status = 'planned' THEN ap.id END) as planned_applications,
|
||||
COALESCE(SUM(al.area_covered), 0) as total_area_treated,
|
||||
COALESCE(AVG(al.average_speed), 0) as avg_application_speed
|
||||
FROM application_logs al
|
||||
FULL OUTER JOIN application_plans ap ON al.plan_id = ap.id OR ap.user_id = $1
|
||||
WHERE EXTRACT(YEAR FROM COALESCE(al.application_date, ap.planned_date)) = $2
|
||||
AND (al.user_id = $1 OR ap.user_id = $1)
|
||||
`;
|
||||
|
||||
const statsResult = await pool.query(statsQuery, [req.user.id, year]);
|
||||
const stats = statsResult.rows[0];
|
||||
|
||||
// Get monthly breakdown
|
||||
const monthlyQuery = `
|
||||
SELECT
|
||||
EXTRACT(MONTH FROM al.application_date) as month,
|
||||
COUNT(*) as applications,
|
||||
COALESCE(SUM(al.area_covered), 0) as area_covered
|
||||
FROM application_logs al
|
||||
WHERE al.user_id = $1
|
||||
AND EXTRACT(YEAR FROM al.application_date) = $2
|
||||
GROUP BY EXTRACT(MONTH FROM al.application_date)
|
||||
ORDER BY month
|
||||
`;
|
||||
|
||||
const monthlyResult = await pool.query(monthlyQuery, [req.user.id, year]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
stats: {
|
||||
totalApplications: parseInt(stats.total_applications) || 0,
|
||||
totalPlans: parseInt(stats.total_plans) || 0,
|
||||
completedPlans: parseInt(stats.completed_plans) || 0,
|
||||
plannedApplications: parseInt(stats.planned_applications) || 0,
|
||||
totalAreaTreated: parseFloat(stats.total_area_treated) || 0,
|
||||
avgApplicationSpeed: parseFloat(stats.avg_application_speed) || 0,
|
||||
completionRate: stats.total_plans > 0 ?
|
||||
Math.round((stats.completed_plans / stats.total_plans) * 100) : 0
|
||||
},
|
||||
monthlyBreakdown: monthlyResult.rows.map(row => ({
|
||||
month: parseInt(row.month),
|
||||
applications: parseInt(row.applications),
|
||||
areaCovered: parseFloat(row.area_covered)
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
313
backend/src/routes/auth.js
Normal file
313
backend/src/routes/auth.js
Normal file
@@ -0,0 +1,313 @@
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const passport = require('passport');
|
||||
const GoogleStrategy = require('passport-google-oauth20').Strategy;
|
||||
const pool = require('../config/database');
|
||||
const { validateRequest } = require('../utils/validation');
|
||||
const { registerSchema, loginSchema, changePasswordSchema } = require('../utils/validation');
|
||||
const { AppError } = require('../middleware/errorHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Configure Google OAuth2 strategy
|
||||
if (process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET) {
|
||||
passport.use(new GoogleStrategy({
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: '/api/auth/google/callback'
|
||||
}, async (accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
// Check if user already exists
|
||||
const existingUser = await pool.query(
|
||||
'SELECT * FROM users WHERE oauth_provider = $1 AND oauth_id = $2',
|
||||
['google', profile.id]
|
||||
);
|
||||
|
||||
if (existingUser.rows.length > 0) {
|
||||
return done(null, existingUser.rows[0]);
|
||||
}
|
||||
|
||||
// Check if user exists with same email
|
||||
const emailUser = await pool.query(
|
||||
'SELECT * FROM users WHERE email = $1',
|
||||
[profile.emails[0].value]
|
||||
);
|
||||
|
||||
if (emailUser.rows.length > 0) {
|
||||
// Link Google account to existing user
|
||||
await pool.query(
|
||||
'UPDATE users SET oauth_provider = $1, oauth_id = $2 WHERE id = $3',
|
||||
['google', profile.id, emailUser.rows[0].id]
|
||||
);
|
||||
return done(null, emailUser.rows[0]);
|
||||
}
|
||||
|
||||
// Create new user
|
||||
const newUser = await pool.query(
|
||||
`INSERT INTO users (email, first_name, last_name, oauth_provider, oauth_id)
|
||||
VALUES ($1, $2, $3, $4, $5) RETURNING *`,
|
||||
[
|
||||
profile.emails[0].value,
|
||||
profile.name.givenName,
|
||||
profile.name.familyName,
|
||||
'google',
|
||||
profile.id
|
||||
]
|
||||
);
|
||||
|
||||
return done(null, newUser.rows[0]);
|
||||
} catch (error) {
|
||||
return done(error, null);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
passport.serializeUser((user, done) => {
|
||||
done(null, user.id);
|
||||
});
|
||||
|
||||
passport.deserializeUser(async (id, done) => {
|
||||
try {
|
||||
const user = await pool.query('SELECT * FROM users WHERE id = $1', [id]);
|
||||
done(null, user.rows[0]);
|
||||
} catch (error) {
|
||||
done(error, null);
|
||||
}
|
||||
});
|
||||
|
||||
// Generate JWT token
|
||||
const generateToken = (userId) => {
|
||||
return jwt.sign(
|
||||
{ userId },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
};
|
||||
|
||||
// @route POST /api/auth/register
|
||||
// @desc Register a new user
|
||||
// @access Public
|
||||
router.post('/register', validateRequest(registerSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { email, password, firstName, lastName } = req.body;
|
||||
|
||||
// Check if user already exists
|
||||
const existingUser = await pool.query(
|
||||
'SELECT id FROM users WHERE email = $1',
|
||||
[email.toLowerCase()]
|
||||
);
|
||||
|
||||
if (existingUser.rows.length > 0) {
|
||||
throw new AppError('User with this email already exists', 409);
|
||||
}
|
||||
|
||||
// Hash password
|
||||
const saltRounds = 12;
|
||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// Create user
|
||||
const newUser = await pool.query(
|
||||
`INSERT INTO users (email, password_hash, first_name, last_name)
|
||||
VALUES ($1, $2, $3, $4) RETURNING id, email, first_name, last_name, role`,
|
||||
[email.toLowerCase(), passwordHash, firstName, lastName]
|
||||
);
|
||||
|
||||
const user = newUser.rows[0];
|
||||
const token = generateToken(user.id);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'User registered successfully',
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
role: user.role
|
||||
},
|
||||
token
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/auth/login
|
||||
// @desc Login user
|
||||
// @access Public
|
||||
router.post('/login', validateRequest(loginSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// Find user
|
||||
const userResult = await pool.query(
|
||||
'SELECT id, email, password_hash, first_name, last_name, role FROM users WHERE email = $1',
|
||||
[email.toLowerCase()]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
throw new AppError('Invalid email or password', 401);
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
// Check password
|
||||
if (!user.password_hash) {
|
||||
throw new AppError('Please use Google sign-in for this account', 401);
|
||||
}
|
||||
|
||||
const isValidPassword = await bcrypt.compare(password, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
throw new AppError('Invalid email or password', 401);
|
||||
}
|
||||
|
||||
const token = generateToken(user.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Login successful',
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
role: user.role
|
||||
},
|
||||
token
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/auth/google
|
||||
// @desc Start Google OAuth2 flow
|
||||
// @access Public
|
||||
router.get('/google', passport.authenticate('google', {
|
||||
scope: ['profile', 'email']
|
||||
}));
|
||||
|
||||
// @route GET /api/auth/google/callback
|
||||
// @desc Google OAuth2 callback
|
||||
// @access Public
|
||||
router.get('/google/callback',
|
||||
passport.authenticate('google', { session: false }),
|
||||
(req, res) => {
|
||||
const token = generateToken(req.user.id);
|
||||
|
||||
// Redirect to frontend with token
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||
res.redirect(`${frontendUrl}/auth/callback?token=${token}`);
|
||||
}
|
||||
);
|
||||
|
||||
// @route POST /api/auth/change-password
|
||||
// @desc Change user password
|
||||
// @access Private
|
||||
router.post('/change-password', validateRequest(changePasswordSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Get current user
|
||||
const userResult = await pool.query(
|
||||
'SELECT password_hash FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
if (!user.password_hash) {
|
||||
throw new AppError('Cannot change password for OAuth accounts', 400);
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const isValidPassword = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
throw new AppError('Current password is incorrect', 401);
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
const saltRounds = 12;
|
||||
const newPasswordHash = await bcrypt.hash(newPassword, saltRounds);
|
||||
|
||||
// Update password
|
||||
await pool.query(
|
||||
'UPDATE users SET password_hash = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
[newPasswordHash, userId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Password changed successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/auth/forgot-password
|
||||
// @desc Request password reset
|
||||
// @access Public
|
||||
router.post('/forgot-password', async (req, res, next) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
|
||||
// Check if user exists
|
||||
const userResult = await pool.query(
|
||||
'SELECT id, first_name FROM users WHERE email = $1',
|
||||
[email.toLowerCase()]
|
||||
);
|
||||
|
||||
// Always return success for security (don't reveal if email exists)
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'If an account with that email exists, a password reset link has been sent'
|
||||
});
|
||||
|
||||
// TODO: Implement email sending logic
|
||||
// In a real application, you would:
|
||||
// 1. Generate a secure reset token
|
||||
// 2. Store it in database with expiration
|
||||
// 3. Send email with reset link
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/auth/me
|
||||
// @desc Get current user info
|
||||
// @access Private
|
||||
router.get('/me', async (req, res, next) => {
|
||||
try {
|
||||
const userResult = await pool.query(
|
||||
'SELECT id, email, first_name, last_name, role, created_at FROM users WHERE id = $1',
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
role: user.role,
|
||||
createdAt: user.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
448
backend/src/routes/equipment.js
Normal file
448
backend/src/routes/equipment.js
Normal file
@@ -0,0 +1,448 @@
|
||||
const express = require('express');
|
||||
const pool = require('../config/database');
|
||||
const { validateRequest, validateParams } = require('../utils/validation');
|
||||
const { equipmentSchema, idParamSchema } = require('../utils/validation');
|
||||
const { AppError } = require('../middleware/errorHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// @route GET /api/equipment/types
|
||||
// @desc Get all equipment types
|
||||
// @access Private
|
||||
router.get('/types', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM equipment_types ORDER BY category, name'
|
||||
);
|
||||
|
||||
const equipmentByCategory = result.rows.reduce((acc, equipment) => {
|
||||
if (!acc[equipment.category]) {
|
||||
acc[equipment.category] = [];
|
||||
}
|
||||
acc[equipment.category].push({
|
||||
id: equipment.id,
|
||||
name: equipment.name,
|
||||
category: equipment.category,
|
||||
createdAt: equipment.created_at
|
||||
});
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
equipmentTypes: result.rows,
|
||||
equipmentByCategory
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/equipment
|
||||
// @desc Get all equipment for current user
|
||||
// @access Private
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT ue.*, et.name as type_name, et.category
|
||||
FROM user_equipment ue
|
||||
JOIN equipment_types et ON ue.equipment_type_id = et.id
|
||||
WHERE ue.user_id = $1
|
||||
ORDER BY et.category, et.name`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
equipment: result.rows.map(item => ({
|
||||
id: item.id,
|
||||
equipmentTypeId: item.equipment_type_id,
|
||||
typeName: item.type_name,
|
||||
category: item.category,
|
||||
customName: item.custom_name,
|
||||
tankSize: parseFloat(item.tank_size),
|
||||
pumpGpm: parseFloat(item.pump_gpm),
|
||||
nozzleGpm: parseFloat(item.nozzle_gpm),
|
||||
nozzleCount: item.nozzle_count,
|
||||
spreaderWidth: parseFloat(item.spreader_width),
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/equipment/:id
|
||||
// @desc Get single equipment item
|
||||
// @access Private
|
||||
router.get('/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const equipmentId = req.params.id;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT ue.*, et.name as type_name, et.category
|
||||
FROM user_equipment ue
|
||||
JOIN equipment_types et ON ue.equipment_type_id = et.id
|
||||
WHERE ue.id = $1 AND ue.user_id = $2`,
|
||||
[equipmentId, req.user.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new AppError('Equipment not found', 404);
|
||||
}
|
||||
|
||||
const item = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
equipment: {
|
||||
id: item.id,
|
||||
equipmentTypeId: item.equipment_type_id,
|
||||
typeName: item.type_name,
|
||||
category: item.category,
|
||||
customName: item.custom_name,
|
||||
tankSize: parseFloat(item.tank_size),
|
||||
pumpGpm: parseFloat(item.pump_gpm),
|
||||
nozzleGpm: parseFloat(item.nozzle_gpm),
|
||||
nozzleCount: item.nozzle_count,
|
||||
spreaderWidth: parseFloat(item.spreader_width),
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/equipment
|
||||
// @desc Add new equipment
|
||||
// @access Private
|
||||
router.post('/', validateRequest(equipmentSchema), async (req, res, next) => {
|
||||
try {
|
||||
const {
|
||||
equipmentTypeId,
|
||||
customName,
|
||||
tankSize,
|
||||
pumpGpm,
|
||||
nozzleGpm,
|
||||
nozzleCount,
|
||||
spreaderWidth
|
||||
} = req.body;
|
||||
|
||||
// Verify equipment type exists
|
||||
const typeCheck = await pool.query(
|
||||
'SELECT id, name, category FROM equipment_types WHERE id = $1',
|
||||
[equipmentTypeId]
|
||||
);
|
||||
|
||||
if (typeCheck.rows.length === 0) {
|
||||
throw new AppError('Equipment type not found', 404);
|
||||
}
|
||||
|
||||
const equipmentType = typeCheck.rows[0];
|
||||
|
||||
// Validate required fields based on equipment type
|
||||
if (equipmentType.category === 'sprayer') {
|
||||
if (!tankSize || !pumpGpm || !nozzleGpm || !nozzleCount) {
|
||||
throw new AppError('Tank size, pump GPM, nozzle GPM, and nozzle count are required for sprayers', 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (equipmentType.category === 'spreader' && !spreaderWidth) {
|
||||
throw new AppError('Spreader width is required for spreaders', 400);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO user_equipment
|
||||
(user_id, equipment_type_id, custom_name, tank_size, pump_gpm, nozzle_gpm, nozzle_count, spreader_width)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING *`,
|
||||
[req.user.id, equipmentTypeId, customName, tankSize, pumpGpm, nozzleGpm, nozzleCount, spreaderWidth]
|
||||
);
|
||||
|
||||
const equipment = result.rows[0];
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Equipment added successfully',
|
||||
data: {
|
||||
equipment: {
|
||||
id: equipment.id,
|
||||
equipmentTypeId: equipment.equipment_type_id,
|
||||
typeName: equipmentType.name,
|
||||
category: equipmentType.category,
|
||||
customName: equipment.custom_name,
|
||||
tankSize: parseFloat(equipment.tank_size),
|
||||
pumpGpm: parseFloat(equipment.pump_gpm),
|
||||
nozzleGpm: parseFloat(equipment.nozzle_gpm),
|
||||
nozzleCount: equipment.nozzle_count,
|
||||
spreaderWidth: parseFloat(equipment.spreader_width),
|
||||
createdAt: equipment.created_at,
|
||||
updatedAt: equipment.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/equipment/:id
|
||||
// @desc Update equipment
|
||||
// @access Private
|
||||
router.put('/:id', validateParams(idParamSchema), validateRequest(equipmentSchema), async (req, res, next) => {
|
||||
try {
|
||||
const equipmentId = req.params.id;
|
||||
const {
|
||||
equipmentTypeId,
|
||||
customName,
|
||||
tankSize,
|
||||
pumpGpm,
|
||||
nozzleGpm,
|
||||
nozzleCount,
|
||||
spreaderWidth
|
||||
} = req.body;
|
||||
|
||||
// Check if equipment exists and belongs to user
|
||||
const checkResult = await pool.query(
|
||||
'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2',
|
||||
[equipmentId, req.user.id]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length === 0) {
|
||||
throw new AppError('Equipment not found', 404);
|
||||
}
|
||||
|
||||
// Verify equipment type exists
|
||||
const typeCheck = await pool.query(
|
||||
'SELECT id, name, category FROM equipment_types WHERE id = $1',
|
||||
[equipmentTypeId]
|
||||
);
|
||||
|
||||
if (typeCheck.rows.length === 0) {
|
||||
throw new AppError('Equipment type not found', 404);
|
||||
}
|
||||
|
||||
const equipmentType = typeCheck.rows[0];
|
||||
|
||||
// Validate required fields based on equipment type
|
||||
if (equipmentType.category === 'sprayer') {
|
||||
if (!tankSize || !pumpGpm || !nozzleGpm || !nozzleCount) {
|
||||
throw new AppError('Tank size, pump GPM, nozzle GPM, and nozzle count are required for sprayers', 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (equipmentType.category === 'spreader' && !spreaderWidth) {
|
||||
throw new AppError('Spreader width is required for spreaders', 400);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE user_equipment
|
||||
SET equipment_type_id = $1, custom_name = $2, tank_size = $3, pump_gpm = $4,
|
||||
nozzle_gpm = $5, nozzle_count = $6, spreader_width = $7, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $8
|
||||
RETURNING *`,
|
||||
[equipmentTypeId, customName, tankSize, pumpGpm, nozzleGpm, nozzleCount, spreaderWidth, equipmentId]
|
||||
);
|
||||
|
||||
const equipment = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Equipment updated successfully',
|
||||
data: {
|
||||
equipment: {
|
||||
id: equipment.id,
|
||||
equipmentTypeId: equipment.equipment_type_id,
|
||||
typeName: equipmentType.name,
|
||||
category: equipmentType.category,
|
||||
customName: equipment.custom_name,
|
||||
tankSize: parseFloat(equipment.tank_size),
|
||||
pumpGpm: parseFloat(equipment.pump_gpm),
|
||||
nozzleGpm: parseFloat(equipment.nozzle_gpm),
|
||||
nozzleCount: equipment.nozzle_count,
|
||||
spreaderWidth: parseFloat(equipment.spreader_width),
|
||||
createdAt: equipment.created_at,
|
||||
updatedAt: equipment.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/equipment/:id
|
||||
// @desc Delete equipment
|
||||
// @access Private
|
||||
router.delete('/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const equipmentId = req.params.id;
|
||||
|
||||
// Check if equipment exists and belongs to user
|
||||
const checkResult = await pool.query(
|
||||
'SELECT id FROM user_equipment WHERE id = $1 AND user_id = $2',
|
||||
[equipmentId, req.user.id]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length === 0) {
|
||||
throw new AppError('Equipment not found', 404);
|
||||
}
|
||||
|
||||
// Check if equipment is used in any applications
|
||||
const usageCheck = await pool.query(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM application_plans
|
||||
WHERE equipment_id = $1 AND status IN ('planned', 'in_progress')`,
|
||||
[equipmentId]
|
||||
);
|
||||
|
||||
if (parseInt(usageCheck.rows[0].count) > 0) {
|
||||
throw new AppError('Cannot delete equipment with active applications', 400);
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM user_equipment WHERE id = $1', [equipmentId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Equipment deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/equipment/:id/calculations
|
||||
// @desc Get application calculations for equipment
|
||||
// @access Private
|
||||
router.get('/:id/calculations', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const equipmentId = req.params.id;
|
||||
const { area, rateAmount, rateUnit } = req.query;
|
||||
|
||||
if (!area || !rateAmount || !rateUnit) {
|
||||
throw new AppError('Area, rate amount, and rate unit are required for calculations', 400);
|
||||
}
|
||||
|
||||
// Get equipment details
|
||||
const equipmentResult = await pool.query(
|
||||
`SELECT ue.*, et.category
|
||||
FROM user_equipment ue
|
||||
JOIN equipment_types et ON ue.equipment_type_id = et.id
|
||||
WHERE ue.id = $1 AND ue.user_id = $2`,
|
||||
[equipmentId, req.user.id]
|
||||
);
|
||||
|
||||
if (equipmentResult.rows.length === 0) {
|
||||
throw new AppError('Equipment not found', 404);
|
||||
}
|
||||
|
||||
const equipment = equipmentResult.rows[0];
|
||||
const targetArea = parseFloat(area);
|
||||
const rate = parseFloat(rateAmount);
|
||||
|
||||
let calculations = {};
|
||||
|
||||
if (equipment.category === 'sprayer') {
|
||||
// Liquid application calculations
|
||||
const tankSize = parseFloat(equipment.tank_size);
|
||||
const pumpGpm = parseFloat(equipment.pump_gpm);
|
||||
const nozzleGpm = parseFloat(equipment.nozzle_gpm);
|
||||
const nozzleCount = parseInt(equipment.nozzle_count);
|
||||
|
||||
// Calculate total nozzle output
|
||||
const totalNozzleGpm = nozzleGpm * nozzleCount;
|
||||
|
||||
let productAmount, waterAmount, targetSpeed;
|
||||
|
||||
if (rateUnit.includes('gal/1000sqft') || rateUnit.includes('gal/acre')) {
|
||||
// Gallons per area - calculate water volume needed
|
||||
const multiplier = rateUnit.includes('acre') ? targetArea / 43560 : targetArea / 1000;
|
||||
waterAmount = rate * multiplier;
|
||||
productAmount = 0; // Pure water application
|
||||
} else if (rateUnit.includes('oz/gal/1000sqft')) {
|
||||
// Ounces per gallon per 1000 sqft
|
||||
const waterGallonsNeeded = targetArea / 1000; // 1 gallon per 1000 sqft default
|
||||
productAmount = rate * waterGallonsNeeded;
|
||||
waterAmount = waterGallonsNeeded * 128; // Convert to ounces
|
||||
} else {
|
||||
// Default liquid calculation
|
||||
productAmount = rate * (targetArea / 1000);
|
||||
waterAmount = tankSize * 128; // Tank capacity in ounces
|
||||
}
|
||||
|
||||
// Calculate target speed (assuming 20 foot spray width as default)
|
||||
const sprayWidth = 20; // feet
|
||||
const minutesToCover = waterAmount / (totalNozzleGpm * 128); // Convert GPM to oz/min
|
||||
const distanceFeet = targetArea / sprayWidth;
|
||||
targetSpeed = (distanceFeet / minutesToCover) * (60 / 5280); // Convert to MPH
|
||||
|
||||
calculations = {
|
||||
productAmount: Math.round(productAmount * 100) / 100,
|
||||
waterAmount: Math.round(waterAmount * 100) / 100,
|
||||
targetSpeed: Math.round(targetSpeed * 100) / 100,
|
||||
tankCount: Math.ceil(waterAmount / (tankSize * 128)),
|
||||
applicationType: 'liquid',
|
||||
unit: rateUnit.includes('oz') ? 'oz' : 'gal'
|
||||
};
|
||||
} else if (equipment.category === 'spreader') {
|
||||
// Granular application calculations
|
||||
const spreaderWidth = parseFloat(equipment.spreader_width);
|
||||
|
||||
let productAmount, targetSpeed;
|
||||
|
||||
if (rateUnit.includes('lbs/1000sqft')) {
|
||||
productAmount = rate * (targetArea / 1000);
|
||||
} else if (rateUnit.includes('lbs/acre')) {
|
||||
productAmount = rate * (targetArea / 43560);
|
||||
} else {
|
||||
productAmount = rate * (targetArea / 1000); // Default to per 1000 sqft
|
||||
}
|
||||
|
||||
// Calculate target speed (assuming 3 MPH walking speed as baseline)
|
||||
const baselineSpeed = 3; // MPH
|
||||
const minutesToSpread = 60; // Assume 1 hour coverage
|
||||
const distanceFeet = targetArea / spreaderWidth;
|
||||
targetSpeed = (distanceFeet / (minutesToSpread * 60)) * (60 / 5280); // Convert to MPH
|
||||
|
||||
calculations = {
|
||||
productAmount: Math.round(productAmount * 100) / 100,
|
||||
targetSpeed: Math.round(targetSpeed * 100) / 100,
|
||||
applicationType: 'granular',
|
||||
unit: 'lbs',
|
||||
coverageTime: Math.round((targetArea / (spreaderWidth * baselineSpeed * 5280 / 60)) * 100) / 100
|
||||
};
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
calculations,
|
||||
equipment: {
|
||||
id: equipment.id,
|
||||
category: equipment.category,
|
||||
tankSize: equipment.tank_size,
|
||||
spreaderWidth: equipment.spreader_width
|
||||
},
|
||||
inputs: {
|
||||
area: targetArea,
|
||||
rate: rate,
|
||||
unit: rateUnit
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
537
backend/src/routes/products.js
Normal file
537
backend/src/routes/products.js
Normal file
@@ -0,0 +1,537 @@
|
||||
const express = require('express');
|
||||
const pool = require('../config/database');
|
||||
const { validateRequest, validateParams } = require('../utils/validation');
|
||||
const { productSchema, productRateSchema, userProductSchema, idParamSchema } = require('../utils/validation');
|
||||
const { AppError } = require('../middleware/errorHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// @route GET /api/products/categories
|
||||
// @desc Get all product categories
|
||||
// @access Private
|
||||
router.get('/categories', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT * FROM product_categories ORDER BY name'
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
categories: result.rows
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/products
|
||||
// @desc Get all products (shared + user's custom products)
|
||||
// @access Private
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const { category, type, search } = req.query;
|
||||
|
||||
let whereConditions = [];
|
||||
let queryParams = [req.user.id];
|
||||
let paramCount = 1;
|
||||
|
||||
// Build WHERE clause for filtering
|
||||
if (category) {
|
||||
paramCount++;
|
||||
whereConditions.push(`pc.id = $${paramCount}`);
|
||||
queryParams.push(category);
|
||||
}
|
||||
|
||||
if (type) {
|
||||
paramCount++;
|
||||
whereConditions.push(`p.product_type = $${paramCount}`);
|
||||
queryParams.push(type);
|
||||
}
|
||||
|
||||
if (search) {
|
||||
paramCount++;
|
||||
whereConditions.push(`(p.name ILIKE $${paramCount} OR p.brand ILIKE $${paramCount} OR p.active_ingredients ILIKE $${paramCount})`);
|
||||
queryParams.push(`%${search}%`);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0 ? `AND ${whereConditions.join(' AND ')}` : '';
|
||||
|
||||
// Get shared products
|
||||
const sharedProductsQuery = `
|
||||
SELECT p.*, pc.name as category_name,
|
||||
array_agg(
|
||||
json_build_object(
|
||||
'id', pr.id,
|
||||
'applicationType', pr.application_type,
|
||||
'rateAmount', pr.rate_amount,
|
||||
'rateUnit', pr.rate_unit,
|
||||
'notes', pr.notes
|
||||
)
|
||||
) FILTER (WHERE pr.id IS NOT NULL) as rates
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
LEFT JOIN product_rates pr ON p.id = pr.product_id
|
||||
WHERE 1=1 ${whereClause}
|
||||
GROUP BY p.id, pc.name
|
||||
ORDER BY p.name
|
||||
`;
|
||||
|
||||
const sharedResult = await pool.query(sharedProductsQuery, queryParams.slice(1));
|
||||
|
||||
// Get user's custom products
|
||||
const userProductsQuery = `
|
||||
SELECT up.*, p.name as base_product_name, p.brand, p.product_type, pc.name as category_name
|
||||
FROM user_products up
|
||||
LEFT JOIN products p ON up.product_id = p.id
|
||||
LEFT JOIN product_categories pc ON p.category_id = pc.id
|
||||
WHERE up.user_id = $1
|
||||
ORDER BY COALESCE(up.custom_name, p.name)
|
||||
`;
|
||||
|
||||
const userResult = await pool.query(userProductsQuery, [req.user.id]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
sharedProducts: sharedResult.rows.map(product => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
categoryName: product.category_name,
|
||||
productType: product.product_type,
|
||||
activeIngredients: product.active_ingredients,
|
||||
description: product.description,
|
||||
rates: product.rates || [],
|
||||
isShared: true,
|
||||
createdAt: product.created_at
|
||||
})),
|
||||
userProducts: userResult.rows.map(product => ({
|
||||
id: product.id,
|
||||
baseProductId: product.product_id,
|
||||
baseProductName: product.base_product_name,
|
||||
customName: product.custom_name,
|
||||
brand: product.brand,
|
||||
categoryName: product.category_name,
|
||||
productType: product.product_type,
|
||||
customRateAmount: parseFloat(product.custom_rate_amount),
|
||||
customRateUnit: product.custom_rate_unit,
|
||||
notes: product.notes,
|
||||
isShared: false,
|
||||
createdAt: product.created_at
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/products/:id
|
||||
// @desc Get single shared product with rates
|
||||
// @access Private
|
||||
router.get('/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const productId = req.params.id;
|
||||
|
||||
const productResult = await pool.query(
|
||||
`SELECT p.*, pc.name as category_name
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
WHERE p.id = $1`,
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (productResult.rows.length === 0) {
|
||||
throw new AppError('Product not found', 404);
|
||||
}
|
||||
|
||||
const product = productResult.rows[0];
|
||||
|
||||
// Get product rates
|
||||
const ratesResult = await pool.query(
|
||||
'SELECT * FROM product_rates WHERE product_id = $1 ORDER BY application_type',
|
||||
[productId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
product: {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
categoryName: product.category_name,
|
||||
productType: product.product_type,
|
||||
activeIngredients: product.active_ingredients,
|
||||
description: product.description,
|
||||
rates: ratesResult.rows.map(rate => ({
|
||||
id: rate.id,
|
||||
applicationType: rate.application_type,
|
||||
rateAmount: parseFloat(rate.rate_amount),
|
||||
rateUnit: rate.rate_unit,
|
||||
notes: rate.notes,
|
||||
createdAt: rate.created_at
|
||||
})),
|
||||
createdAt: product.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/products
|
||||
// @desc Create new shared product (admin only)
|
||||
// @access Private (Admin)
|
||||
router.post('/', validateRequest(productSchema), async (req, res, next) => {
|
||||
try {
|
||||
// Check if user is admin
|
||||
if (req.user.role !== 'admin') {
|
||||
throw new AppError('Admin access required', 403);
|
||||
}
|
||||
|
||||
const { name, brand, categoryId, productType, activeIngredients, description } = req.body;
|
||||
|
||||
// Check if category exists
|
||||
const categoryCheck = await pool.query(
|
||||
'SELECT id FROM product_categories WHERE id = $1',
|
||||
[categoryId]
|
||||
);
|
||||
|
||||
if (categoryCheck.rows.length === 0) {
|
||||
throw new AppError('Product category not found', 404);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO products (name, brand, category_id, product_type, active_ingredients, description)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[name, brand, categoryId, productType, activeIngredients, description]
|
||||
);
|
||||
|
||||
const product = result.rows[0];
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Product created successfully',
|
||||
data: {
|
||||
product: {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
categoryId: product.category_id,
|
||||
productType: product.product_type,
|
||||
activeIngredients: product.active_ingredients,
|
||||
description: product.description,
|
||||
createdAt: product.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/products/:id/rates
|
||||
// @desc Add application rate to product (admin only)
|
||||
// @access Private (Admin)
|
||||
router.post('/:id/rates', validateParams(idParamSchema), validateRequest(productRateSchema), async (req, res, next) => {
|
||||
try {
|
||||
// Check if user is admin
|
||||
if (req.user.role !== 'admin') {
|
||||
throw new AppError('Admin access required', 403);
|
||||
}
|
||||
|
||||
const productId = req.params.id;
|
||||
const { applicationType, rateAmount, rateUnit, notes } = req.body;
|
||||
|
||||
// Check if product exists
|
||||
const productCheck = await pool.query(
|
||||
'SELECT id FROM products WHERE id = $1',
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (productCheck.rows.length === 0) {
|
||||
throw new AppError('Product not found', 404);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO product_rates (product_id, application_type, rate_amount, rate_unit, notes)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[productId, applicationType, rateAmount, rateUnit, notes]
|
||||
);
|
||||
|
||||
const rate = result.rows[0];
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Application rate added successfully',
|
||||
data: {
|
||||
rate: {
|
||||
id: rate.id,
|
||||
productId: rate.product_id,
|
||||
applicationType: rate.application_type,
|
||||
rateAmount: parseFloat(rate.rate_amount),
|
||||
rateUnit: rate.rate_unit,
|
||||
notes: rate.notes,
|
||||
createdAt: rate.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/products/user
|
||||
// @desc Create user's custom product
|
||||
// @access Private
|
||||
router.post('/user', validateRequest(userProductSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { productId, customName, customRateAmount, customRateUnit, notes } = req.body;
|
||||
|
||||
// If based on existing product, verify it exists
|
||||
if (productId) {
|
||||
const productCheck = await pool.query(
|
||||
'SELECT id FROM products WHERE id = $1',
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (productCheck.rows.length === 0) {
|
||||
throw new AppError('Base product not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
// Require either productId or customName
|
||||
if (!productId && !customName) {
|
||||
throw new AppError('Either base product or custom name is required', 400);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO user_products (user_id, product_id, custom_name, custom_rate_amount, custom_rate_unit, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[req.user.id, productId, customName, customRateAmount, customRateUnit, notes]
|
||||
);
|
||||
|
||||
const userProduct = result.rows[0];
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Custom product created successfully',
|
||||
data: {
|
||||
userProduct: {
|
||||
id: userProduct.id,
|
||||
baseProductId: userProduct.product_id,
|
||||
customName: userProduct.custom_name,
|
||||
customRateAmount: parseFloat(userProduct.custom_rate_amount),
|
||||
customRateUnit: userProduct.custom_rate_unit,
|
||||
notes: userProduct.notes,
|
||||
createdAt: userProduct.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/products/user/:id
|
||||
// @desc Get user's custom product
|
||||
// @access Private
|
||||
router.get('/user/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const userProductId = req.params.id;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT up.*, p.name as base_product_name, p.brand, p.product_type,
|
||||
p.active_ingredients, pc.name as category_name
|
||||
FROM user_products up
|
||||
LEFT JOIN products p ON up.product_id = p.id
|
||||
LEFT JOIN product_categories pc ON p.category_id = pc.id
|
||||
WHERE up.id = $1 AND up.user_id = $2`,
|
||||
[userProductId, req.user.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
throw new AppError('User product not found', 404);
|
||||
}
|
||||
|
||||
const userProduct = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
userProduct: {
|
||||
id: userProduct.id,
|
||||
baseProductId: userProduct.product_id,
|
||||
baseProductName: userProduct.base_product_name,
|
||||
customName: userProduct.custom_name,
|
||||
brand: userProduct.brand,
|
||||
categoryName: userProduct.category_name,
|
||||
productType: userProduct.product_type,
|
||||
activeIngredients: userProduct.active_ingredients,
|
||||
customRateAmount: parseFloat(userProduct.custom_rate_amount),
|
||||
customRateUnit: userProduct.custom_rate_unit,
|
||||
notes: userProduct.notes,
|
||||
createdAt: userProduct.created_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/products/user/:id
|
||||
// @desc Update user's custom product
|
||||
// @access Private
|
||||
router.put('/user/:id', validateParams(idParamSchema), validateRequest(userProductSchema), async (req, res, next) => {
|
||||
try {
|
||||
const userProductId = req.params.id;
|
||||
const { productId, customName, customRateAmount, customRateUnit, notes } = req.body;
|
||||
|
||||
// Check if user product exists and belongs to user
|
||||
const checkResult = await pool.query(
|
||||
'SELECT id FROM user_products WHERE id = $1 AND user_id = $2',
|
||||
[userProductId, req.user.id]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length === 0) {
|
||||
throw new AppError('User product not found', 404);
|
||||
}
|
||||
|
||||
// If changing base product, verify it exists
|
||||
if (productId) {
|
||||
const productCheck = await pool.query(
|
||||
'SELECT id FROM products WHERE id = $1',
|
||||
[productId]
|
||||
);
|
||||
|
||||
if (productCheck.rows.length === 0) {
|
||||
throw new AppError('Base product not found', 404);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE user_products
|
||||
SET product_id = $1, custom_name = $2, custom_rate_amount = $3,
|
||||
custom_rate_unit = $4, notes = $5, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $6
|
||||
RETURNING *`,
|
||||
[productId, customName, customRateAmount, customRateUnit, notes, userProductId]
|
||||
);
|
||||
|
||||
const userProduct = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Custom product updated successfully',
|
||||
data: {
|
||||
userProduct: {
|
||||
id: userProduct.id,
|
||||
baseProductId: userProduct.product_id,
|
||||
customName: userProduct.custom_name,
|
||||
customRateAmount: parseFloat(userProduct.custom_rate_amount),
|
||||
customRateUnit: userProduct.custom_rate_unit,
|
||||
notes: userProduct.notes,
|
||||
createdAt: userProduct.created_at,
|
||||
updatedAt: userProduct.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/products/user/:id
|
||||
// @desc Delete user's custom product
|
||||
// @access Private
|
||||
router.delete('/user/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const userProductId = req.params.id;
|
||||
|
||||
// Check if user product exists and belongs to user
|
||||
const checkResult = await pool.query(
|
||||
'SELECT id FROM user_products WHERE id = $1 AND user_id = $2',
|
||||
[userProductId, req.user.id]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length === 0) {
|
||||
throw new AppError('User product not found', 404);
|
||||
}
|
||||
|
||||
// Check if product is used in any applications
|
||||
const usageCheck = await pool.query(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM application_plan_products
|
||||
WHERE user_product_id = $1`,
|
||||
[userProductId]
|
||||
);
|
||||
|
||||
if (parseInt(usageCheck.rows[0].count) > 0) {
|
||||
throw new AppError('Cannot delete product that has been used in applications', 400);
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM user_products WHERE id = $1', [userProductId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Custom product deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/products/search
|
||||
// @desc Search products by name or ingredients
|
||||
// @access Private
|
||||
router.get('/search', async (req, res, next) => {
|
||||
try {
|
||||
const { q, limit = 10 } = req.query;
|
||||
|
||||
if (!q || q.length < 2) {
|
||||
throw new AppError('Search query must be at least 2 characters', 400);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT p.*, pc.name as category_name
|
||||
FROM products p
|
||||
JOIN product_categories pc ON p.category_id = pc.id
|
||||
WHERE p.name ILIKE $1 OR p.brand ILIKE $1 OR p.active_ingredients ILIKE $1
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN p.name ILIKE $2 THEN 1
|
||||
WHEN p.brand ILIKE $2 THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
p.name
|
||||
LIMIT $3`,
|
||||
[`%${q}%`, `${q}%`, limit]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
products: result.rows.map(product => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
brand: product.brand,
|
||||
categoryName: product.category_name,
|
||||
productType: product.product_type,
|
||||
activeIngredients: product.active_ingredients
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
410
backend/src/routes/properties.js
Normal file
410
backend/src/routes/properties.js
Normal file
@@ -0,0 +1,410 @@
|
||||
const express = require('express');
|
||||
const pool = require('../config/database');
|
||||
const { validateRequest, validateParams } = require('../utils/validation');
|
||||
const { propertySchema, lawnSectionSchema, idParamSchema } = require('../utils/validation');
|
||||
const { AppError } = require('../middleware/errorHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to calculate polygon area (in square feet)
|
||||
const calculatePolygonArea = (coordinates) => {
|
||||
if (!coordinates || coordinates.length < 3) return 0;
|
||||
|
||||
// Shoelace formula for polygon area
|
||||
let area = 0;
|
||||
const n = coordinates.length;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = (i + 1) % n;
|
||||
area += coordinates[i][0] * coordinates[j][1];
|
||||
area -= coordinates[j][0] * coordinates[i][1];
|
||||
}
|
||||
|
||||
area = Math.abs(area) / 2;
|
||||
|
||||
// Convert from decimal degrees to square feet (approximate)
|
||||
// This is a rough approximation - in production you'd use proper geodesic calculations
|
||||
const avgLat = coordinates.reduce((sum, coord) => sum + coord[1], 0) / n;
|
||||
const meterToFeet = 3.28084;
|
||||
const degToMeter = 111320 * Math.cos(avgLat * Math.PI / 180);
|
||||
|
||||
return area * Math.pow(degToMeter * meterToFeet, 2);
|
||||
};
|
||||
|
||||
// @route GET /api/properties
|
||||
// @desc Get all properties for current user
|
||||
// @access Private
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT p.*,
|
||||
COUNT(ls.id) as section_count,
|
||||
COALESCE(SUM(ls.area), 0) as calculated_area
|
||||
FROM properties p
|
||||
LEFT JOIN lawn_sections ls ON p.id = ls.property_id
|
||||
WHERE p.user_id = $1
|
||||
GROUP BY p.id
|
||||
ORDER BY p.created_at DESC`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
properties: result.rows.map(row => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
latitude: parseFloat(row.latitude),
|
||||
longitude: parseFloat(row.longitude),
|
||||
totalArea: parseFloat(row.total_area),
|
||||
calculatedArea: parseFloat(row.calculated_area),
|
||||
sectionCount: parseInt(row.section_count),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
}))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/properties/:id
|
||||
// @desc Get single property with sections
|
||||
// @access Private
|
||||
router.get('/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const propertyId = req.params.id;
|
||||
|
||||
// Get property
|
||||
const propertyResult = await pool.query(
|
||||
'SELECT * FROM properties WHERE id = $1 AND user_id = $2',
|
||||
[propertyId, req.user.id]
|
||||
);
|
||||
|
||||
if (propertyResult.rows.length === 0) {
|
||||
throw new AppError('Property not found', 404);
|
||||
}
|
||||
|
||||
const property = propertyResult.rows[0];
|
||||
|
||||
// Get lawn sections
|
||||
const sectionsResult = await pool.query(
|
||||
'SELECT * FROM lawn_sections WHERE property_id = $1 ORDER BY name',
|
||||
[propertyId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
property: {
|
||||
id: property.id,
|
||||
name: property.name,
|
||||
address: property.address,
|
||||
latitude: parseFloat(property.latitude),
|
||||
longitude: parseFloat(property.longitude),
|
||||
totalArea: parseFloat(property.total_area),
|
||||
createdAt: property.created_at,
|
||||
updatedAt: property.updated_at,
|
||||
sections: sectionsResult.rows.map(section => ({
|
||||
id: section.id,
|
||||
name: section.name,
|
||||
area: parseFloat(section.area),
|
||||
polygonData: section.polygon_data,
|
||||
grassType: section.grass_type,
|
||||
soilType: section.soil_type,
|
||||
createdAt: section.created_at,
|
||||
updatedAt: section.updated_at
|
||||
}))
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/properties
|
||||
// @desc Create new property
|
||||
// @access Private
|
||||
router.post('/', validateRequest(propertySchema), async (req, res, next) => {
|
||||
try {
|
||||
const { name, address, latitude, longitude, totalArea } = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO properties (user_id, name, address, latitude, longitude, total_area)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[req.user.id, name, address, latitude, longitude, totalArea]
|
||||
);
|
||||
|
||||
const property = result.rows[0];
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Property created successfully',
|
||||
data: {
|
||||
property: {
|
||||
id: property.id,
|
||||
name: property.name,
|
||||
address: property.address,
|
||||
latitude: parseFloat(property.latitude),
|
||||
longitude: parseFloat(property.longitude),
|
||||
totalArea: parseFloat(property.total_area),
|
||||
createdAt: property.created_at,
|
||||
updatedAt: property.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/properties/:id
|
||||
// @desc Update property
|
||||
// @access Private
|
||||
router.put('/:id', validateParams(idParamSchema), validateRequest(propertySchema), async (req, res, next) => {
|
||||
try {
|
||||
const propertyId = req.params.id;
|
||||
const { name, address, latitude, longitude, totalArea } = req.body;
|
||||
|
||||
// Check if property exists and belongs to user
|
||||
const checkResult = await pool.query(
|
||||
'SELECT id FROM properties WHERE id = $1 AND user_id = $2',
|
||||
[propertyId, req.user.id]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length === 0) {
|
||||
throw new AppError('Property not found', 404);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE properties
|
||||
SET name = $1, address = $2, latitude = $3, longitude = $4, total_area = $5, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $6
|
||||
RETURNING *`,
|
||||
[name, address, latitude, longitude, totalArea, propertyId]
|
||||
);
|
||||
|
||||
const property = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Property updated successfully',
|
||||
data: {
|
||||
property: {
|
||||
id: property.id,
|
||||
name: property.name,
|
||||
address: property.address,
|
||||
latitude: parseFloat(property.latitude),
|
||||
longitude: parseFloat(property.longitude),
|
||||
totalArea: parseFloat(property.total_area),
|
||||
createdAt: property.created_at,
|
||||
updatedAt: property.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/properties/:id
|
||||
// @desc Delete property
|
||||
// @access Private
|
||||
router.delete('/:id', validateParams(idParamSchema), async (req, res, next) => {
|
||||
try {
|
||||
const propertyId = req.params.id;
|
||||
|
||||
// Check if property exists and belongs to user
|
||||
const checkResult = await pool.query(
|
||||
'SELECT id FROM properties WHERE id = $1 AND user_id = $2',
|
||||
[propertyId, req.user.id]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length === 0) {
|
||||
throw new AppError('Property not found', 404);
|
||||
}
|
||||
|
||||
// Check for active applications
|
||||
const activeApps = await pool.query(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM application_plans ap
|
||||
JOIN lawn_sections ls ON ap.lawn_section_id = ls.id
|
||||
WHERE ls.property_id = $1 AND ap.status IN ('planned', 'in_progress')`,
|
||||
[propertyId]
|
||||
);
|
||||
|
||||
if (parseInt(activeApps.rows[0].count) > 0) {
|
||||
throw new AppError('Cannot delete property with active applications', 400);
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM properties WHERE id = $1', [propertyId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Property deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route POST /api/properties/:id/sections
|
||||
// @desc Create lawn section for property
|
||||
// @access Private
|
||||
router.post('/:id/sections', validateParams(idParamSchema), validateRequest(lawnSectionSchema), async (req, res, next) => {
|
||||
try {
|
||||
const propertyId = req.params.id;
|
||||
const { name, area, polygonData, grassType, soilType } = req.body;
|
||||
|
||||
// Check if property exists and belongs to user
|
||||
const propertyResult = await pool.query(
|
||||
'SELECT id FROM properties WHERE id = $1 AND user_id = $2',
|
||||
[propertyId, req.user.id]
|
||||
);
|
||||
|
||||
if (propertyResult.rows.length === 0) {
|
||||
throw new AppError('Property not found', 404);
|
||||
}
|
||||
|
||||
// Calculate area from polygon if provided
|
||||
let calculatedArea = area;
|
||||
if (polygonData && polygonData.coordinates && polygonData.coordinates[0]) {
|
||||
calculatedArea = calculatePolygonArea(polygonData.coordinates[0]);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO lawn_sections (property_id, name, area, polygon_data, grass_type, soil_type)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING *`,
|
||||
[propertyId, name, calculatedArea, JSON.stringify(polygonData), grassType, soilType]
|
||||
);
|
||||
|
||||
const section = result.rows[0];
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Lawn section created successfully',
|
||||
data: {
|
||||
section: {
|
||||
id: section.id,
|
||||
name: section.name,
|
||||
area: parseFloat(section.area),
|
||||
polygonData: section.polygon_data,
|
||||
grassType: section.grass_type,
|
||||
soilType: section.soil_type,
|
||||
createdAt: section.created_at,
|
||||
updatedAt: section.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/properties/:propertyId/sections/:sectionId
|
||||
// @desc Update lawn section
|
||||
// @access Private
|
||||
router.put('/:propertyId/sections/:sectionId', async (req, res, next) => {
|
||||
try {
|
||||
const { propertyId, sectionId } = req.params;
|
||||
const { name, area, polygonData, grassType, soilType } = req.body;
|
||||
|
||||
// Check if section exists and user owns the property
|
||||
const checkResult = await pool.query(
|
||||
`SELECT ls.id
|
||||
FROM lawn_sections ls
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
WHERE ls.id = $1 AND p.id = $2 AND p.user_id = $3`,
|
||||
[sectionId, propertyId, req.user.id]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length === 0) {
|
||||
throw new AppError('Lawn section not found', 404);
|
||||
}
|
||||
|
||||
// Calculate area from polygon if provided
|
||||
let calculatedArea = area;
|
||||
if (polygonData && polygonData.coordinates && polygonData.coordinates[0]) {
|
||||
calculatedArea = calculatePolygonArea(polygonData.coordinates[0]);
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE lawn_sections
|
||||
SET name = $1, area = $2, polygon_data = $3, grass_type = $4, soil_type = $5, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $6
|
||||
RETURNING *`,
|
||||
[name, calculatedArea, JSON.stringify(polygonData), grassType, soilType, sectionId]
|
||||
);
|
||||
|
||||
const section = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Lawn section updated successfully',
|
||||
data: {
|
||||
section: {
|
||||
id: section.id,
|
||||
name: section.name,
|
||||
area: parseFloat(section.area),
|
||||
polygonData: section.polygon_data,
|
||||
grassType: section.grass_type,
|
||||
soilType: section.soil_type,
|
||||
createdAt: section.created_at,
|
||||
updatedAt: section.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/properties/:propertyId/sections/:sectionId
|
||||
// @desc Delete lawn section
|
||||
// @access Private
|
||||
router.delete('/:propertyId/sections/:sectionId', async (req, res, next) => {
|
||||
try {
|
||||
const { propertyId, sectionId } = req.params;
|
||||
|
||||
// Check if section exists and user owns the property
|
||||
const checkResult = await pool.query(
|
||||
`SELECT ls.id
|
||||
FROM lawn_sections ls
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
WHERE ls.id = $1 AND p.id = $2 AND p.user_id = $3`,
|
||||
[sectionId, propertyId, req.user.id]
|
||||
);
|
||||
|
||||
if (checkResult.rows.length === 0) {
|
||||
throw new AppError('Lawn section not found', 404);
|
||||
}
|
||||
|
||||
// Check for active applications
|
||||
const activeApps = await pool.query(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM application_plans
|
||||
WHERE lawn_section_id = $1 AND status IN ('planned', 'in_progress')`,
|
||||
[sectionId]
|
||||
);
|
||||
|
||||
if (parseInt(activeApps.rows[0].count) > 0) {
|
||||
throw new AppError('Cannot delete section with active applications', 400);
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM lawn_sections WHERE id = $1', [sectionId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Lawn section deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
225
backend/src/routes/users.js
Normal file
225
backend/src/routes/users.js
Normal file
@@ -0,0 +1,225 @@
|
||||
const express = require('express');
|
||||
const pool = require('../config/database');
|
||||
const { validateRequest, validateParams } = require('../utils/validation');
|
||||
const { updateUserSchema, idParamSchema } = require('../utils/validation');
|
||||
const { requireOwnership } = require('../middleware/auth');
|
||||
const { AppError } = require('../middleware/errorHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// @route GET /api/users/profile
|
||||
// @desc Get current user profile
|
||||
// @access Private
|
||||
router.get('/profile', async (req, res, next) => {
|
||||
try {
|
||||
const userResult = await pool.query(
|
||||
`SELECT u.id, u.email, u.first_name, u.last_name, u.role, u.created_at, u.updated_at,
|
||||
COUNT(p.id) as property_count
|
||||
FROM users u
|
||||
LEFT JOIN properties p ON u.id = p.user_id
|
||||
WHERE u.id = $1
|
||||
GROUP BY u.id`,
|
||||
[req.user.id]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
throw new AppError('User not found', 404);
|
||||
}
|
||||
|
||||
const user = userResult.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
role: user.role,
|
||||
propertyCount: parseInt(user.property_count),
|
||||
createdAt: user.created_at,
|
||||
updatedAt: user.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route PUT /api/users/profile
|
||||
// @desc Update current user profile
|
||||
// @access Private
|
||||
router.put('/profile', validateRequest(updateUserSchema), async (req, res, next) => {
|
||||
try {
|
||||
const { firstName, lastName, email } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Check if email is already taken by another user
|
||||
if (email) {
|
||||
const emailCheck = await pool.query(
|
||||
'SELECT id FROM users WHERE email = $1 AND id != $2',
|
||||
[email.toLowerCase(), userId]
|
||||
);
|
||||
|
||||
if (emailCheck.rows.length > 0) {
|
||||
throw new AppError('Email is already taken by another user', 409);
|
||||
}
|
||||
}
|
||||
|
||||
// Build dynamic update query
|
||||
const updates = [];
|
||||
const values = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (firstName) {
|
||||
updates.push(`first_name = $${paramCount}`);
|
||||
values.push(firstName);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (lastName) {
|
||||
updates.push(`last_name = $${paramCount}`);
|
||||
values.push(lastName);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (email) {
|
||||
updates.push(`email = $${paramCount}`);
|
||||
values.push(email.toLowerCase());
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
throw new AppError('No valid fields to update', 400);
|
||||
}
|
||||
|
||||
updates.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||
values.push(userId);
|
||||
|
||||
const query = `
|
||||
UPDATE users
|
||||
SET ${updates.join(', ')}
|
||||
WHERE id = $${paramCount}
|
||||
RETURNING id, email, first_name, last_name, role, updated_at
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, values);
|
||||
const user = result.rows[0];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Profile updated successfully',
|
||||
data: {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.first_name,
|
||||
lastName: user.last_name,
|
||||
role: user.role,
|
||||
updatedAt: user.updated_at
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route DELETE /api/users/account
|
||||
// @desc Delete current user account
|
||||
// @access Private
|
||||
router.delete('/account', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
// Check if user has any ongoing applications
|
||||
const ongoingApps = await pool.query(
|
||||
`SELECT COUNT(*) as count
|
||||
FROM application_plans
|
||||
WHERE user_id = $1 AND status IN ('planned', 'in_progress')`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (parseInt(ongoingApps.rows[0].count) > 0) {
|
||||
throw new AppError('Cannot delete account with ongoing applications. Please complete or cancel them first.', 400);
|
||||
}
|
||||
|
||||
// Delete user (cascading will handle related records)
|
||||
await pool.query('DELETE FROM users WHERE id = $1', [userId]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Account deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/users/stats
|
||||
// @desc Get user statistics
|
||||
// @access Private
|
||||
router.get('/stats', async (req, res, next) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
const statsQuery = `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM properties WHERE user_id = $1) as total_properties,
|
||||
(SELECT COUNT(*) FROM lawn_sections ls
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
WHERE p.user_id = $1) as total_sections,
|
||||
(SELECT COALESCE(SUM(ls.area), 0) FROM lawn_sections ls
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
WHERE p.user_id = $1) as total_area,
|
||||
(SELECT COUNT(*) FROM user_equipment WHERE user_id = $1) as total_equipment,
|
||||
(SELECT COUNT(*) FROM application_plans WHERE user_id = $1) as total_plans,
|
||||
(SELECT COUNT(*) FROM application_logs WHERE user_id = $1) as total_applications,
|
||||
(SELECT COUNT(*) FROM application_plans
|
||||
WHERE user_id = $1 AND status = 'completed') as completed_plans,
|
||||
(SELECT COUNT(*) FROM application_plans
|
||||
WHERE user_id = $1 AND planned_date >= CURRENT_DATE) as upcoming_plans
|
||||
`;
|
||||
|
||||
const result = await pool.query(statsQuery, [userId]);
|
||||
const stats = result.rows[0];
|
||||
|
||||
// Get recent activity
|
||||
const recentActivity = await pool.query(
|
||||
`SELECT 'application' as type, al.application_date as date,
|
||||
ls.name as section_name, p.name as property_name
|
||||
FROM application_logs al
|
||||
JOIN lawn_sections ls ON al.lawn_section_id = ls.id
|
||||
JOIN properties p ON ls.property_id = p.id
|
||||
WHERE al.user_id = $1
|
||||
ORDER BY al.application_date DESC
|
||||
LIMIT 5`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
stats: {
|
||||
totalProperties: parseInt(stats.total_properties),
|
||||
totalSections: parseInt(stats.total_sections),
|
||||
totalArea: parseFloat(stats.total_area),
|
||||
totalEquipment: parseInt(stats.total_equipment),
|
||||
totalPlans: parseInt(stats.total_plans),
|
||||
totalApplications: parseInt(stats.total_applications),
|
||||
completedPlans: parseInt(stats.completed_plans),
|
||||
upcomingPlans: parseInt(stats.upcoming_plans),
|
||||
completionRate: stats.total_plans > 0 ?
|
||||
Math.round((stats.completed_plans / stats.total_plans) * 100) : 0
|
||||
},
|
||||
recentActivity: recentActivity.rows
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
454
backend/src/routes/weather.js
Normal file
454
backend/src/routes/weather.js
Normal file
@@ -0,0 +1,454 @@
|
||||
const express = require('express');
|
||||
const axios = require('axios');
|
||||
const pool = require('../config/database');
|
||||
const { AppError } = require('../middleware/errorHandler');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// @route GET /api/weather/:propertyId
|
||||
// @desc Get current weather for property location
|
||||
// @access Private
|
||||
router.get('/:propertyId', async (req, res, next) => {
|
||||
try {
|
||||
const propertyId = req.params.propertyId;
|
||||
|
||||
// Verify property belongs to user
|
||||
const propertyResult = await pool.query(
|
||||
'SELECT id, name, latitude, longitude FROM properties WHERE id = $1 AND user_id = $2',
|
||||
[propertyId, req.user.id]
|
||||
);
|
||||
|
||||
if (propertyResult.rows.length === 0) {
|
||||
throw new AppError('Property not found', 404);
|
||||
}
|
||||
|
||||
const property = propertyResult.rows[0];
|
||||
|
||||
if (!property.latitude || !property.longitude) {
|
||||
throw new AppError('Property location coordinates not set', 400);
|
||||
}
|
||||
|
||||
const apiKey = process.env.WEATHER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new AppError('Weather service not configured', 503);
|
||||
}
|
||||
|
||||
// Fetch current weather from OpenWeatherMap
|
||||
const weatherResponse = await axios.get(
|
||||
`https://api.openweathermap.org/data/2.5/weather`,
|
||||
{
|
||||
params: {
|
||||
lat: property.latitude,
|
||||
lon: property.longitude,
|
||||
appid: apiKey,
|
||||
units: 'imperial'
|
||||
},
|
||||
timeout: 5000
|
||||
}
|
||||
);
|
||||
|
||||
const weatherData = weatherResponse.data;
|
||||
|
||||
// Store weather data in cache
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
await pool.query(
|
||||
`INSERT INTO weather_data
|
||||
(property_id, date, temperature_high, temperature_low, humidity, wind_speed, precipitation, conditions)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (property_id, date)
|
||||
DO UPDATE SET
|
||||
temperature_high = EXCLUDED.temperature_high,
|
||||
temperature_low = EXCLUDED.temperature_low,
|
||||
humidity = EXCLUDED.humidity,
|
||||
wind_speed = EXCLUDED.wind_speed,
|
||||
precipitation = EXCLUDED.precipitation,
|
||||
conditions = EXCLUDED.conditions,
|
||||
created_at = CURRENT_TIMESTAMP`,
|
||||
[
|
||||
propertyId,
|
||||
today,
|
||||
Math.round(weatherData.main.temp),
|
||||
Math.round(weatherData.main.temp_min),
|
||||
weatherData.main.humidity,
|
||||
weatherData.wind?.speed || 0,
|
||||
weatherData.rain?.['1h'] || 0,
|
||||
weatherData.weather[0].description
|
||||
]
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
weather: {
|
||||
location: {
|
||||
propertyId: property.id,
|
||||
propertyName: property.name,
|
||||
latitude: parseFloat(property.latitude),
|
||||
longitude: parseFloat(property.longitude)
|
||||
},
|
||||
current: {
|
||||
temperature: Math.round(weatherData.main.temp),
|
||||
feelsLike: Math.round(weatherData.main.feels_like),
|
||||
humidity: weatherData.main.humidity,
|
||||
pressure: weatherData.main.pressure,
|
||||
windSpeed: weatherData.wind?.speed || 0,
|
||||
windDirection: weatherData.wind?.deg || 0,
|
||||
visibility: weatherData.visibility ? weatherData.visibility / 1609.34 : null, // Convert to miles
|
||||
uvIndex: weatherData.uvi || null,
|
||||
conditions: weatherData.weather[0].description,
|
||||
icon: weatherData.weather[0].icon
|
||||
},
|
||||
precipitation: {
|
||||
current: weatherData.rain?.['1h'] || 0,
|
||||
forecast3h: weatherData.rain?.['3h'] || 0
|
||||
},
|
||||
timestamps: {
|
||||
sunrise: new Date(weatherData.sys.sunrise * 1000),
|
||||
sunset: new Date(weatherData.sys.sunset * 1000),
|
||||
lastUpdated: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
|
||||
throw new AppError('Weather service temporarily unavailable', 503);
|
||||
}
|
||||
if (error.response?.status === 401) {
|
||||
throw new AppError('Weather service authentication failed', 503);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/weather/:propertyId/forecast
|
||||
// @desc Get 5-day weather forecast for property
|
||||
// @access Private
|
||||
router.get('/:propertyId/forecast', async (req, res, next) => {
|
||||
try {
|
||||
const propertyId = req.params.propertyId;
|
||||
|
||||
// Verify property belongs to user
|
||||
const propertyResult = await pool.query(
|
||||
'SELECT id, name, latitude, longitude FROM properties WHERE id = $1 AND user_id = $2',
|
||||
[propertyId, req.user.id]
|
||||
);
|
||||
|
||||
if (propertyResult.rows.length === 0) {
|
||||
throw new AppError('Property not found', 404);
|
||||
}
|
||||
|
||||
const property = propertyResult.rows[0];
|
||||
|
||||
if (!property.latitude || !property.longitude) {
|
||||
throw new AppError('Property location coordinates not set', 400);
|
||||
}
|
||||
|
||||
const apiKey = process.env.WEATHER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new AppError('Weather service not configured', 503);
|
||||
}
|
||||
|
||||
// Fetch 5-day forecast from OpenWeatherMap
|
||||
const forecastResponse = await axios.get(
|
||||
`https://api.openweathermap.org/data/2.5/forecast`,
|
||||
{
|
||||
params: {
|
||||
lat: property.latitude,
|
||||
lon: property.longitude,
|
||||
appid: apiKey,
|
||||
units: 'imperial'
|
||||
},
|
||||
timeout: 5000
|
||||
}
|
||||
);
|
||||
|
||||
const forecastData = forecastResponse.data;
|
||||
|
||||
// Group forecast by day
|
||||
const dailyForecast = {};
|
||||
|
||||
forecastData.list.forEach(item => {
|
||||
const date = item.dt_txt.split(' ')[0];
|
||||
|
||||
if (!dailyForecast[date]) {
|
||||
dailyForecast[date] = {
|
||||
date,
|
||||
temperatures: [],
|
||||
humidity: [],
|
||||
windSpeed: [],
|
||||
precipitation: 0,
|
||||
conditions: [],
|
||||
timestamps: []
|
||||
};
|
||||
}
|
||||
|
||||
dailyForecast[date].temperatures.push(item.main.temp);
|
||||
dailyForecast[date].humidity.push(item.main.humidity);
|
||||
dailyForecast[date].windSpeed.push(item.wind?.speed || 0);
|
||||
dailyForecast[date].precipitation += item.rain?.['3h'] || 0;
|
||||
dailyForecast[date].conditions.push(item.weather[0].description);
|
||||
dailyForecast[date].timestamps.push(new Date(item.dt * 1000));
|
||||
});
|
||||
|
||||
// Process daily summaries
|
||||
const forecast = Object.values(dailyForecast).map(day => {
|
||||
const temps = day.temperatures;
|
||||
const humidity = day.humidity;
|
||||
const windSpeeds = day.windSpeed;
|
||||
|
||||
return {
|
||||
date: day.date,
|
||||
temperatureHigh: Math.round(Math.max(...temps)),
|
||||
temperatureLow: Math.round(Math.min(...temps)),
|
||||
averageHumidity: Math.round(humidity.reduce((a, b) => a + b, 0) / humidity.length),
|
||||
maxWindSpeed: Math.round(Math.max(...windSpeeds)),
|
||||
totalPrecipitation: Math.round(day.precipitation * 100) / 100,
|
||||
conditions: day.conditions[0], // Use first condition of the day
|
||||
timestamps: day.timestamps
|
||||
};
|
||||
}).slice(0, 5); // Limit to 5 days
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
forecast: {
|
||||
location: {
|
||||
propertyId: property.id,
|
||||
propertyName: property.name,
|
||||
latitude: parseFloat(property.latitude),
|
||||
longitude: parseFloat(property.longitude)
|
||||
},
|
||||
daily: forecast,
|
||||
lastUpdated: new Date()
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
|
||||
throw new AppError('Weather service temporarily unavailable', 503);
|
||||
}
|
||||
if (error.response?.status === 401) {
|
||||
throw new AppError('Weather service authentication failed', 503);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/weather/:propertyId/history
|
||||
// @desc Get weather history for property
|
||||
// @access Private
|
||||
router.get('/:propertyId/history', async (req, res, next) => {
|
||||
try {
|
||||
const propertyId = req.params.propertyId;
|
||||
const { start_date, end_date, limit = 30 } = req.query;
|
||||
|
||||
// Verify property belongs to user
|
||||
const propertyResult = await pool.query(
|
||||
'SELECT id, name FROM properties WHERE id = $1 AND user_id = $2',
|
||||
[propertyId, req.user.id]
|
||||
);
|
||||
|
||||
if (propertyResult.rows.length === 0) {
|
||||
throw new AppError('Property not found', 404);
|
||||
}
|
||||
|
||||
const property = propertyResult.rows[0];
|
||||
|
||||
let whereConditions = ['property_id = $1'];
|
||||
let queryParams = [propertyId];
|
||||
let paramCount = 1;
|
||||
|
||||
if (start_date) {
|
||||
paramCount++;
|
||||
whereConditions.push(`date >= $${paramCount}`);
|
||||
queryParams.push(start_date);
|
||||
}
|
||||
|
||||
if (end_date) {
|
||||
paramCount++;
|
||||
whereConditions.push(`date <= $${paramCount}`);
|
||||
queryParams.push(end_date);
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.join(' AND ');
|
||||
|
||||
paramCount++;
|
||||
queryParams.push(limit);
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT * FROM weather_data
|
||||
WHERE ${whereClause}
|
||||
ORDER BY date DESC
|
||||
LIMIT $${paramCount}`,
|
||||
queryParams
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
history: {
|
||||
location: {
|
||||
propertyId: property.id,
|
||||
propertyName: property.name
|
||||
},
|
||||
records: result.rows.map(record => ({
|
||||
date: record.date,
|
||||
temperatureHigh: record.temperature_high,
|
||||
temperatureLow: record.temperature_low,
|
||||
humidity: record.humidity,
|
||||
windSpeed: parseFloat(record.wind_speed),
|
||||
precipitation: parseFloat(record.precipitation),
|
||||
conditions: record.conditions,
|
||||
recordedAt: record.created_at
|
||||
}))
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// @route GET /api/weather/conditions/suitable
|
||||
// @desc Check if weather conditions are suitable for application
|
||||
// @access Private
|
||||
router.get('/conditions/suitable/:propertyId', async (req, res, next) => {
|
||||
try {
|
||||
const propertyId = req.params.propertyId;
|
||||
const { application_type = 'general' } = req.query;
|
||||
|
||||
// Verify property belongs to user
|
||||
const propertyResult = await pool.query(
|
||||
'SELECT id, name, latitude, longitude FROM properties WHERE id = $1 AND user_id = $2',
|
||||
[propertyId, req.user.id]
|
||||
);
|
||||
|
||||
if (propertyResult.rows.length === 0) {
|
||||
throw new AppError('Property not found', 404);
|
||||
}
|
||||
|
||||
const property = propertyResult.rows[0];
|
||||
|
||||
if (!property.latitude || !property.longitude) {
|
||||
throw new AppError('Property location coordinates not set', 400);
|
||||
}
|
||||
|
||||
const apiKey = process.env.WEATHER_API_KEY;
|
||||
if (!apiKey) {
|
||||
throw new AppError('Weather service not configured', 503);
|
||||
}
|
||||
|
||||
// Get current weather
|
||||
const weatherResponse = await axios.get(
|
||||
`https://api.openweathermap.org/data/2.5/weather`,
|
||||
{
|
||||
params: {
|
||||
lat: property.latitude,
|
||||
lon: property.longitude,
|
||||
appid: apiKey,
|
||||
units: 'imperial'
|
||||
},
|
||||
timeout: 5000
|
||||
}
|
||||
);
|
||||
|
||||
const weather = weatherResponse.data;
|
||||
|
||||
// Define suitability criteria based on application type
|
||||
const criteria = {
|
||||
general: {
|
||||
maxWindSpeed: 10, // mph
|
||||
maxTemperature: 85, // °F
|
||||
minTemperature: 45, // °F
|
||||
maxHumidity: 85, // %
|
||||
maxPrecipitation: 0.1 // inches
|
||||
},
|
||||
herbicide: {
|
||||
maxWindSpeed: 7,
|
||||
maxTemperature: 80,
|
||||
minTemperature: 50,
|
||||
maxHumidity: 80,
|
||||
maxPrecipitation: 0
|
||||
},
|
||||
fertilizer: {
|
||||
maxWindSpeed: 15,
|
||||
maxTemperature: 90,
|
||||
minTemperature: 40,
|
||||
maxHumidity: 90,
|
||||
maxPrecipitation: 0
|
||||
}
|
||||
};
|
||||
|
||||
const rules = criteria[application_type] || criteria.general;
|
||||
|
||||
// Check conditions
|
||||
const windSpeed = weather.wind?.speed || 0;
|
||||
const temperature = weather.main.temp;
|
||||
const humidity = weather.main.humidity;
|
||||
const precipitation = weather.rain?.['1h'] || 0;
|
||||
|
||||
const checks = {
|
||||
windSpeed: {
|
||||
suitable: windSpeed <= rules.maxWindSpeed,
|
||||
value: windSpeed,
|
||||
threshold: rules.maxWindSpeed,
|
||||
message: windSpeed > rules.maxWindSpeed ?
|
||||
`Wind speed too high (${windSpeed} mph > ${rules.maxWindSpeed} mph)` : 'Wind speed acceptable'
|
||||
},
|
||||
temperature: {
|
||||
suitable: temperature >= rules.minTemperature && temperature <= rules.maxTemperature,
|
||||
value: temperature,
|
||||
range: [rules.minTemperature, rules.maxTemperature],
|
||||
message: temperature < rules.minTemperature ?
|
||||
`Temperature too low (${Math.round(temperature)}°F < ${rules.minTemperature}°F)` :
|
||||
temperature > rules.maxTemperature ?
|
||||
`Temperature too high (${Math.round(temperature)}°F > ${rules.maxTemperature}°F)` :
|
||||
'Temperature acceptable'
|
||||
},
|
||||
humidity: {
|
||||
suitable: humidity <= rules.maxHumidity,
|
||||
value: humidity,
|
||||
threshold: rules.maxHumidity,
|
||||
message: humidity > rules.maxHumidity ?
|
||||
`Humidity too high (${humidity}% > ${rules.maxHumidity}%)` : 'Humidity acceptable'
|
||||
},
|
||||
precipitation: {
|
||||
suitable: precipitation <= rules.maxPrecipitation,
|
||||
value: precipitation,
|
||||
threshold: rules.maxPrecipitation,
|
||||
message: precipitation > rules.maxPrecipitation ?
|
||||
`Active precipitation (${precipitation} in/hr)` : 'No precipitation'
|
||||
}
|
||||
};
|
||||
|
||||
const overallSuitable = Object.values(checks).every(check => check.suitable);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
suitability: {
|
||||
overall: overallSuitable,
|
||||
applicationType: application_type,
|
||||
checks,
|
||||
recommendations: overallSuitable ?
|
||||
['Conditions are suitable for application'] :
|
||||
Object.values(checks)
|
||||
.filter(check => !check.suitable)
|
||||
.map(check => check.message),
|
||||
lastUpdated: new Date()
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ECONNREFUSED' || error.code === 'ETIMEDOUT') {
|
||||
throw new AppError('Weather service temporarily unavailable', 503);
|
||||
}
|
||||
if (error.response?.status === 401) {
|
||||
throw new AppError('Weather service authentication failed', 503);
|
||||
}
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
164
backend/src/utils/validation.js
Normal file
164
backend/src/utils/validation.js
Normal file
@@ -0,0 +1,164 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
// User validation schemas
|
||||
const registerSchema = Joi.object({
|
||||
email: Joi.string().email().required(),
|
||||
password: Joi.string().min(8).pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/).required()
|
||||
.messages({
|
||||
'string.pattern.base': 'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character'
|
||||
}),
|
||||
firstName: Joi.string().max(100).required(),
|
||||
lastName: Joi.string().max(100).required()
|
||||
});
|
||||
|
||||
const loginSchema = Joi.object({
|
||||
email: Joi.string().email().required(),
|
||||
password: Joi.string().required()
|
||||
});
|
||||
|
||||
const updateUserSchema = Joi.object({
|
||||
firstName: Joi.string().max(100),
|
||||
lastName: Joi.string().max(100),
|
||||
email: Joi.string().email()
|
||||
});
|
||||
|
||||
const changePasswordSchema = Joi.object({
|
||||
currentPassword: Joi.string().required(),
|
||||
newPassword: Joi.string().min(8).pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/).required()
|
||||
.messages({
|
||||
'string.pattern.base': 'Password must contain at least one lowercase letter, one uppercase letter, one number, and one special character'
|
||||
})
|
||||
});
|
||||
|
||||
// Property validation schemas
|
||||
const propertySchema = Joi.object({
|
||||
name: Joi.string().max(255).required(),
|
||||
address: Joi.string().max(500),
|
||||
latitude: Joi.number().min(-90).max(90),
|
||||
longitude: Joi.number().min(-180).max(180),
|
||||
totalArea: Joi.number().positive()
|
||||
});
|
||||
|
||||
const lawnSectionSchema = Joi.object({
|
||||
name: Joi.string().max(255).required(),
|
||||
area: Joi.number().positive().required(),
|
||||
polygonData: Joi.object(),
|
||||
grassType: Joi.string().max(100),
|
||||
soilType: Joi.string().max(100)
|
||||
});
|
||||
|
||||
// Equipment validation schemas
|
||||
const equipmentSchema = Joi.object({
|
||||
equipmentTypeId: Joi.number().integer().positive().required(),
|
||||
customName: Joi.string().max(255),
|
||||
tankSize: Joi.number().positive(),
|
||||
pumpGpm: Joi.number().positive(),
|
||||
nozzleGpm: Joi.number().positive(),
|
||||
nozzleCount: Joi.number().integer().positive(),
|
||||
spreaderWidth: Joi.number().positive()
|
||||
});
|
||||
|
||||
// Product validation schemas
|
||||
const productSchema = Joi.object({
|
||||
name: Joi.string().max(255).required(),
|
||||
brand: Joi.string().max(100),
|
||||
categoryId: Joi.number().integer().positive().required(),
|
||||
productType: Joi.string().valid('granular', 'liquid').required(),
|
||||
activeIngredients: Joi.string(),
|
||||
description: Joi.string()
|
||||
});
|
||||
|
||||
const productRateSchema = Joi.object({
|
||||
applicationType: Joi.string().max(100).required(),
|
||||
rateAmount: Joi.number().positive().required(),
|
||||
rateUnit: Joi.string().max(50).required(),
|
||||
notes: Joi.string()
|
||||
});
|
||||
|
||||
const userProductSchema = Joi.object({
|
||||
productId: Joi.number().integer().positive(),
|
||||
customName: Joi.string().max(255),
|
||||
customRateAmount: Joi.number().positive(),
|
||||
customRateUnit: Joi.string().max(50),
|
||||
notes: Joi.string()
|
||||
});
|
||||
|
||||
// Application validation schemas
|
||||
const applicationPlanSchema = Joi.object({
|
||||
lawnSectionId: Joi.number().integer().positive().required(),
|
||||
equipmentId: Joi.number().integer().positive().required(),
|
||||
plannedDate: Joi.date().required(),
|
||||
notes: Joi.string(),
|
||||
products: Joi.array().items(Joi.object({
|
||||
productId: Joi.number().integer().positive(),
|
||||
userProductId: Joi.number().integer().positive(),
|
||||
rateAmount: Joi.number().positive().required(),
|
||||
rateUnit: Joi.string().max(50).required()
|
||||
})).min(1).required()
|
||||
});
|
||||
|
||||
const applicationLogSchema = Joi.object({
|
||||
planId: Joi.number().integer().positive(),
|
||||
lawnSectionId: Joi.number().integer().positive().required(),
|
||||
equipmentId: Joi.number().integer().positive().required(),
|
||||
weatherConditions: Joi.object(),
|
||||
gpsTrack: Joi.object(),
|
||||
averageSpeed: Joi.number().positive(),
|
||||
areaCovered: Joi.number().positive(),
|
||||
notes: Joi.string(),
|
||||
products: Joi.array().items(Joi.object({
|
||||
productId: Joi.number().integer().positive(),
|
||||
userProductId: Joi.number().integer().positive(),
|
||||
rateAmount: Joi.number().positive().required(),
|
||||
rateUnit: Joi.string().max(50).required(),
|
||||
actualProductAmount: Joi.number().positive(),
|
||||
actualWaterAmount: Joi.number().positive(),
|
||||
actualSpeedMph: Joi.number().positive()
|
||||
})).min(1).required()
|
||||
});
|
||||
|
||||
// Validation middleware
|
||||
const validateRequest = (schema) => {
|
||||
return (req, res, next) => {
|
||||
const { error } = schema.validate(req.body, { abortEarly: false });
|
||||
if (error) {
|
||||
return next(error);
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
const validateParams = (schema) => {
|
||||
return (req, res, next) => {
|
||||
const { error } = schema.validate(req.params, { abortEarly: false });
|
||||
if (error) {
|
||||
return next(error);
|
||||
}
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
const idParamSchema = Joi.object({
|
||||
id: Joi.number().integer().positive().required()
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
// Schemas
|
||||
registerSchema,
|
||||
loginSchema,
|
||||
updateUserSchema,
|
||||
changePasswordSchema,
|
||||
propertySchema,
|
||||
lawnSectionSchema,
|
||||
equipmentSchema,
|
||||
productSchema,
|
||||
productRateSchema,
|
||||
userProductSchema,
|
||||
applicationPlanSchema,
|
||||
applicationLogSchema,
|
||||
idParamSchema,
|
||||
|
||||
// Middleware
|
||||
validateRequest,
|
||||
validateParams
|
||||
};
|
||||
245
database/init.sql
Normal file
245
database/init.sql
Normal file
@@ -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();
|
||||
60
docker-compose.yml
Normal file
60
docker-compose.yml
Normal file
@@ -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:
|
||||
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# 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"]
|
||||
64
frontend/package.json
Normal file
64
frontend/package.json
Normal file
@@ -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"
|
||||
}
|
||||
49
frontend/public/index.html
Normal file
49
frontend/public/index.html
Normal file
@@ -0,0 +1,49 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#059669" />
|
||||
<meta name="description" content="TurfTracker - Professional lawn care management and tracking application" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Progressive Web App meta tags -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="TurfTracker">
|
||||
|
||||
<title>TurfTracker - Lawn Care Management</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- Loading indicator -->
|
||||
<div id="loading-indicator" class="fixed inset-0 flex items-center justify-center bg-green-50 z-50">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-green-600 mx-auto mb-4"></div>
|
||||
<h2 class="text-xl font-semibold text-green-800">TurfTracker</h2>
|
||||
<p class="text-green-600">Loading your lawn care dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Hide loading indicator when React app loads
|
||||
window.addEventListener('load', function() {
|
||||
setTimeout(function() {
|
||||
const loadingIndicator = document.getElementById('loading-indicator');
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
327
frontend/src/App.js
Normal file
327
frontend/src/App.js
Normal file
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (adminOnly && user.role !== 'admin') {
|
||||
return <Navigate to="/unauthorized" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
// Public Route component (redirects to dashboard if already authenticated)
|
||||
const PublicRoute = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<Router>
|
||||
<div className="App">
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route
|
||||
path="/login"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<AuthLayout>
|
||||
<Login />
|
||||
</AuthLayout>
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/register"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<AuthLayout>
|
||||
<Register />
|
||||
</AuthLayout>
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/forgot-password"
|
||||
element={
|
||||
<PublicRoute>
|
||||
<AuthLayout>
|
||||
<ForgotPassword />
|
||||
</AuthLayout>
|
||||
</PublicRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route
|
||||
path="/dashboard"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/properties"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Properties />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/properties/:id"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<PropertyDetail />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/equipment"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Equipment />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/products"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Products />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/applications"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Applications />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/applications/plan"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<ApplicationPlan />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/applications/log"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<ApplicationLog />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/history"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<History />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/weather"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Weather />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<Profile />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin Routes */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute adminOnly>
|
||||
<Layout>
|
||||
<AdminDashboard />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<ProtectedRoute adminOnly>
|
||||
<Layout>
|
||||
<AdminUsers />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/products"
|
||||
element={
|
||||
<ProtectedRoute adminOnly>
|
||||
<Layout>
|
||||
<AdminProducts />
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Error Routes */}
|
||||
<Route path="/unauthorized" element={<Unauthorized />} />
|
||||
<Route path="/404" element={<NotFound />} />
|
||||
|
||||
{/* Redirects */}
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="*" element={<Navigate to="/404" replace />} />
|
||||
</Routes>
|
||||
|
||||
{/* Global Toast Notifications */}
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#fff',
|
||||
color: '#374151',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05)',
|
||||
borderRadius: '0.5rem',
|
||||
border: '1px solid #e5e7eb',
|
||||
},
|
||||
success: {
|
||||
iconTheme: {
|
||||
primary: '#10b981',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Router>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
43
frontend/src/components/Layout/AuthLayout.js
Normal file
43
frontend/src/components/Layout/AuthLayout.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import React from 'react';
|
||||
|
||||
const AuthLayout = ({ children }) => {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-primary-50 to-grass-50 flex flex-col justify-center py-12 sm:px-6 lg:px-8">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto h-16 w-16 bg-primary-600 rounded-full flex items-center justify-center mb-6">
|
||||
<svg
|
||||
className="h-10 w-10 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">TurfTracker</h1>
|
||||
<p className="text-lg text-gray-600">Professional Lawn Care Management</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
|
||||
<div className="bg-white py-8 px-4 shadow-xl rounded-lg sm:px-10 border border-gray-200">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
Track your lawn care with confidence
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthLayout;
|
||||
342
frontend/src/components/Layout/Layout.js
Normal file
342
frontend/src/components/Layout/Layout.js
Normal file
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" message="Loading..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Mobile sidebar */}
|
||||
<div className={`lg:hidden ${sidebarOpen ? 'relative z-40' : ''}`}>
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-600 bg-opacity-75"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
<div className="fixed inset-y-0 left-0 flex w-64 flex-col bg-white shadow-xl">
|
||||
<div className="flex h-16 items-center justify-between px-6 border-b border-gray-200">
|
||||
<h1 className="text-xl font-bold text-primary-600">TurfTracker</h1>
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 px-4 py-6">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname === item.href ||
|
||||
(item.href !== '/dashboard' && location.pathname.startsWith(item.href));
|
||||
const Icon = isActive ? item.iconSolid : item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={isActive ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<Icon className="h-5 w-5 mr-3" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="border-t border-gray-200 my-4" />
|
||||
<div className="px-3 py-2">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Administration
|
||||
</h3>
|
||||
</div>
|
||||
{adminNavigation.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={isActive ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<item.icon className="h-5 w-5 mr-3" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-gray-200 p-4">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user.firstName?.[0]}{user.lastName?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{user.firstName} {user.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Link
|
||||
to="/profile"
|
||||
className="nav-link-inactive"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<UserIcon className="h-5 w-5 mr-3" />
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="nav-link-inactive w-full text-left"
|
||||
>
|
||||
<ArrowRightOnRectangleIcon className="h-5 w-5 mr-3" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<div className="hidden lg:fixed lg:inset-y-0 lg:flex lg:w-64 lg:flex-col">
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-white border-r border-gray-200">
|
||||
<div className="flex h-16 items-center px-6 border-b border-gray-200">
|
||||
<h1 className="text-xl font-bold text-primary-600">TurfTracker</h1>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 space-y-1 px-4 py-6">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname === item.href ||
|
||||
(item.href !== '/dashboard' && location.pathname.startsWith(item.href));
|
||||
const Icon = isActive ? item.iconSolid : item.icon;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={isActive ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
>
|
||||
<Icon className="h-5 w-5 mr-3" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="border-t border-gray-200 my-4" />
|
||||
<div className="px-3 py-2">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">
|
||||
Administration
|
||||
</h3>
|
||||
</div>
|
||||
{adminNavigation.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={isActive ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
>
|
||||
<item.icon className="h-5 w-5 mr-3" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-gray-200 p-4">
|
||||
<div className="flex items-center mb-4">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="h-8 w-8 rounded-full bg-primary-600 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-white">
|
||||
{user.firstName?.[0]}{user.lastName?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{user.firstName} {user.lastName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Link to="/profile" className="nav-link-inactive">
|
||||
<UserIcon className="h-5 w-5 mr-3" />
|
||||
Profile
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="nav-link-inactive w-full text-left"
|
||||
>
|
||||
<ArrowRightOnRectangleIcon className="h-5 w-5 mr-3" />
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-64">
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-10 bg-white border-b border-gray-200 lg:hidden">
|
||||
<div className="flex h-16 items-center justify-between px-4">
|
||||
<button
|
||||
type="button"
|
||||
className="text-gray-500 hover:text-gray-600"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<Bars3Icon className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
<h1 className="text-lg font-semibold text-gray-900">TurfTracker</h1>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<button className="text-gray-400 hover:text-gray-500">
|
||||
<BellIcon className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="h-6 w-6 rounded-full bg-primary-600 flex items-center justify-center">
|
||||
<span className="text-xs font-medium text-white">
|
||||
{user.firstName?.[0]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="min-h-screen">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
43
frontend/src/components/UI/LoadingSpinner.js
Normal file
43
frontend/src/components/UI/LoadingSpinner.js
Normal file
@@ -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 (
|
||||
<div className={clsx('flex flex-col items-center justify-center', className)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'animate-spin rounded-full border-2 border-gray-200',
|
||||
sizeClasses[size],
|
||||
colorClasses[color],
|
||||
'border-t-transparent'
|
||||
)}
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
{message && (
|
||||
<p className="mt-2 text-sm text-gray-600 text-center">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoadingSpinner;
|
||||
232
frontend/src/contexts/AuthContext.js
Normal file
232
frontend/src/contexts/AuthContext.js
Normal file
@@ -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 (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
5
frontend/src/hooks/useAuth.js
Normal file
5
frontend/src/hooks/useAuth.js
Normal file
@@ -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;
|
||||
206
frontend/src/index.css
Normal file
206
frontend/src/index.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
19
frontend/src/index.js
Normal file
19
frontend/src/index.js
Normal file
@@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
// Hide loading indicator
|
||||
const loadingIndicator = document.getElementById('loading-indicator');
|
||||
if (loadingIndicator) {
|
||||
setTimeout(() => {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}, 500);
|
||||
}
|
||||
14
frontend/src/pages/Admin/AdminDashboard.js
Normal file
14
frontend/src/pages/Admin/AdminDashboard.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const AdminDashboard = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Admin Dashboard</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Admin dashboard coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminDashboard;
|
||||
14
frontend/src/pages/Admin/AdminProducts.js
Normal file
14
frontend/src/pages/Admin/AdminProducts.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const AdminProducts = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Manage Products</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Product management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminProducts;
|
||||
14
frontend/src/pages/Admin/AdminUsers.js
Normal file
14
frontend/src/pages/Admin/AdminUsers.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const AdminUsers = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Manage Users</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">User management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUsers;
|
||||
14
frontend/src/pages/Applications/ApplicationLog.js
Normal file
14
frontend/src/pages/Applications/ApplicationLog.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const ApplicationLog = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Log Application</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Application logging coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplicationLog;
|
||||
14
frontend/src/pages/Applications/ApplicationPlan.js
Normal file
14
frontend/src/pages/Applications/ApplicationPlan.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const ApplicationPlan = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Plan Application</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Application planning coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApplicationPlan;
|
||||
14
frontend/src/pages/Applications/Applications.js
Normal file
14
frontend/src/pages/Applications/Applications.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const Applications = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Applications</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Application management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Applications;
|
||||
103
frontend/src/pages/Auth/ForgotPassword.js
Normal file
103
frontend/src/pages/Auth/ForgotPassword.js
Normal file
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Forgot your password?</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
{errors.root && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
{errors.root.message}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={errors.email ? 'input-error' : 'input'}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^\S+@\S+$/i,
|
||||
message: 'Invalid email address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner size="sm" color="white" />
|
||||
) : (
|
||||
'Send reset link'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Back to sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPassword;
|
||||
203
frontend/src/pages/Auth/Login.js
Normal file
203
frontend/src/pages/Auth/Login.js
Normal file
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Sign in to your account</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Or{' '}
|
||||
<Link
|
||||
to="/register"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
create a new account
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
{errors.root && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
{errors.root.message}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={errors.email ? 'input-error' : 'input'}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^\S+@\S+$/i,
|
||||
message: 'Invalid email address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="label">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
className={errors.password ? 'input-error pr-10' : 'input pr-10'}
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="remember-me"
|
||||
name="remember-me"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="remember-me" className="ml-2 block text-sm text-gray-900">
|
||||
Remember me
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner size="sm" color="white" />
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white text-gray-500">Or continue with</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGoogleLogin}
|
||||
className="w-full inline-flex justify-center py-2 px-4 border border-gray-300 rounded-lg shadow-sm bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 transition-colors duration-200"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="ml-2">Sign in with Google</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
248
frontend/src/pages/Auth/Register.js
Normal file
248
frontend/src/pages/Auth/Register.js
Normal file
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900">Create your account</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/login"
|
||||
className="font-medium text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Sign in here
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
|
||||
{errors.root && (
|
||||
<div className="rounded-md bg-red-50 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-red-800">
|
||||
{errors.root.message}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="firstName" className="label">
|
||||
First name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="firstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
className={errors.firstName ? 'input-error' : 'input'}
|
||||
{...register('firstName', {
|
||||
required: 'First name is required',
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: 'First name must be at least 2 characters',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.firstName && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.firstName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="lastName" className="label">
|
||||
Last name
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="lastName"
|
||||
type="text"
|
||||
autoComplete="family-name"
|
||||
className={errors.lastName ? 'input-error' : 'input'}
|
||||
{...register('lastName', {
|
||||
required: 'Last name is required',
|
||||
minLength: {
|
||||
value: 2,
|
||||
message: 'Last name must be at least 2 characters',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.lastName && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.lastName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="label">
|
||||
Email address
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
className={errors.email ? 'input-error' : 'input'}
|
||||
{...register('email', {
|
||||
required: 'Email is required',
|
||||
pattern: {
|
||||
value: /^\S+@\S+$/i,
|
||||
message: 'Invalid email address',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="label">
|
||||
Password
|
||||
</label>
|
||||
<div className="mt-1 relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
className={errors.password ? 'input-error pr-10' : 'input pr-10'}
|
||||
{...register('password', {
|
||||
required: 'Password is required',
|
||||
minLength: {
|
||||
value: 8,
|
||||
message: 'Password must be at least 8 characters',
|
||||
},
|
||||
pattern: {
|
||||
value: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/,
|
||||
message: 'Password must contain uppercase, lowercase, number, and special character',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="label">
|
||||
Confirm password
|
||||
</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className={errors.confirmPassword ? 'input-error' : 'input'}
|
||||
{...register('confirmPassword', {
|
||||
required: 'Please confirm your password',
|
||||
validate: (value) =>
|
||||
value === password || 'Passwords do not match',
|
||||
})}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-2 text-sm text-red-600">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
id="agree-terms"
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
{...register('agreeTerms', {
|
||||
required: 'You must agree to the terms and conditions',
|
||||
})}
|
||||
/>
|
||||
<label htmlFor="agree-terms" className="ml-2 block text-sm text-gray-900">
|
||||
I agree to the{' '}
|
||||
<Link
|
||||
to="/terms"
|
||||
className="text-primary-600 hover:text-primary-500"
|
||||
target="_blank"
|
||||
>
|
||||
Terms and Conditions
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link
|
||||
to="/privacy"
|
||||
className="text-primary-600 hover:text-primary-500"
|
||||
target="_blank"
|
||||
>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</label>
|
||||
</div>
|
||||
{errors.agreeTerms && (
|
||||
<p className="text-sm text-red-600">{errors.agreeTerms.message}</p>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center"
|
||||
>
|
||||
{loading ? (
|
||||
<LoadingSpinner size="sm" color="white" />
|
||||
) : (
|
||||
'Create account'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Register;
|
||||
309
frontend/src/pages/Dashboard/Dashboard.js
Normal file
309
frontend/src/pages/Dashboard/Dashboard.js
Normal file
@@ -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 (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900">
|
||||
Welcome back, {user?.firstName}!
|
||||
</h1>
|
||||
<p className="mt-2 text-gray-600">
|
||||
Here's what's happening with your lawn care today.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.name} className="card">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<stat.icon className="h-8 w-8 text-gray-400" />
|
||||
</div>
|
||||
<div className="ml-4 flex-1">
|
||||
<p className="text-sm font-medium text-gray-500">{stat.name}</p>
|
||||
<div className="flex items-baseline">
|
||||
<p className="text-2xl font-semibold text-gray-900">{stat.value}</p>
|
||||
<span className={`ml-2 text-sm font-medium ${
|
||||
stat.changeType === 'positive' ? 'text-green-600' : 'text-red-600'
|
||||
}`}>
|
||||
{stat.change}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Quick Actions */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="text-lg font-medium text-gray-900">Quick Actions</h3>
|
||||
<p className="text-sm text-gray-500">Get started with common tasks</p>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{quickActions.map((action) => (
|
||||
<Link
|
||||
key={action.name}
|
||||
to={action.href}
|
||||
className="flex items-center p-3 rounded-lg border border-gray-200 hover:border-gray-300 hover:shadow-sm transition-all duration-200"
|
||||
>
|
||||
<div className={`flex-shrink-0 p-2 rounded-lg ${action.color}`}>
|
||||
<action.icon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div className="ml-3 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900">{action.name}</p>
|
||||
<p className="text-xs text-gray-500">{action.description}</p>
|
||||
</div>
|
||||
<PlusIcon className="h-4 w-4 text-gray-400" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity & Upcoming Tasks */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
{/* Recent Activity */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="text-lg font-medium text-gray-900">Recent Activity</h3>
|
||||
<Link
|
||||
to="/history"
|
||||
className="text-sm text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{recentActivity.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start space-x-3">
|
||||
<div className={`flex-shrink-0 p-1 rounded-full ${
|
||||
activity.status === 'completed' ? 'bg-green-100' : 'bg-blue-100'
|
||||
}`}>
|
||||
<div className={`h-2 w-2 rounded-full ${
|
||||
activity.status === 'completed' ? 'bg-green-600' : 'bg-blue-600'
|
||||
}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-900">{activity.title}</p>
|
||||
{activity.property && (
|
||||
<p className="text-xs text-gray-500">{activity.property}</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-400">{activity.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Tasks */}
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="text-lg font-medium text-gray-900">Upcoming Tasks</h3>
|
||||
<Link
|
||||
to="/applications"
|
||||
className="text-sm text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
View all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{upcomingTasks.map((task) => (
|
||||
<div key={task.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900">{task.title}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{task.property}</p>
|
||||
<div className="flex items-center mt-2 space-x-4">
|
||||
<span className="text-xs text-gray-500">{task.date}</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<CloudIcon className="h-3 w-3 text-gray-400" />
|
||||
<span className="text-xs text-gray-500">{task.weather}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
task.priority === 'high'
|
||||
? 'bg-red-100 text-red-800'
|
||||
: task.priority === 'medium'
|
||||
? 'bg-yellow-100 text-yellow-800'
|
||||
: 'bg-green-100 text-green-800'
|
||||
}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weather Widget */}
|
||||
<div className="mt-8">
|
||||
<div className="card">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">Today's Weather</h3>
|
||||
<Link
|
||||
to="/weather"
|
||||
className="text-sm text-primary-600 hover:text-primary-500"
|
||||
>
|
||||
Detailed forecast
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<CloudIcon className="h-8 w-8 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-2xl font-semibold text-gray-900">72°F</p>
|
||||
<p className="text-sm text-gray-500">Partly cloudy</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Wind:</span>
|
||||
<span className="text-sm font-medium text-gray-900">5 mph</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Humidity:</span>
|
||||
<span className="text-sm font-medium text-gray-900">45%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 p-3 bg-green-50 rounded-lg">
|
||||
<p className="text-sm text-green-800">
|
||||
✓ Good conditions for lawn applications today
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
14
frontend/src/pages/Equipment/Equipment.js
Normal file
14
frontend/src/pages/Equipment/Equipment.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const Equipment = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Equipment</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Equipment management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Equipment;
|
||||
46
frontend/src/pages/Error/NotFound.js
Normal file
46
frontend/src/pages/Error/NotFound.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { HomeIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 text-center">
|
||||
<div>
|
||||
<h1 className="text-9xl font-bold text-primary-600">404</h1>
|
||||
<h2 className="mt-6 text-3xl font-bold text-gray-900">
|
||||
Page not found
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Sorry, we couldn't find the page you're looking for.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="btn-primary w-full justify-center"
|
||||
>
|
||||
<HomeIcon className="h-5 w-5 mr-2" />
|
||||
Go back home
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/properties"
|
||||
className="btn-outline w-full justify-center"
|
||||
>
|
||||
View Properties
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<p className="text-xs text-gray-500">
|
||||
If you believe this is an error, please contact support.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
48
frontend/src/pages/Error/Unauthorized.js
Normal file
48
frontend/src/pages/Error/Unauthorized.js
Normal file
@@ -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 (
|
||||
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8 text-center">
|
||||
<div>
|
||||
<div className="mx-auto h-24 w-24 bg-red-100 rounded-full flex items-center justify-center mb-6">
|
||||
<ShieldExclamationIcon className="h-12 w-12 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900">
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
You don't have permission to access this page. Contact your administrator if you believe this is an error.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<Link
|
||||
to="/dashboard"
|
||||
className="btn-primary w-full justify-center"
|
||||
>
|
||||
<HomeIcon className="h-5 w-5 mr-2" />
|
||||
Go back home
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/profile"
|
||||
className="btn-outline w-full justify-center"
|
||||
>
|
||||
View Profile
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<p className="text-xs text-gray-500">
|
||||
Need help? Contact support for assistance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Unauthorized;
|
||||
14
frontend/src/pages/History/History.js
Normal file
14
frontend/src/pages/History/History.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const History = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">History</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Application history coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default History;
|
||||
14
frontend/src/pages/Products/Products.js
Normal file
14
frontend/src/pages/Products/Products.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const Products = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Products</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Product management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Products;
|
||||
14
frontend/src/pages/Profile/Profile.js
Normal file
14
frontend/src/pages/Profile/Profile.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const Profile = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Profile</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Profile management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Profile;
|
||||
14
frontend/src/pages/Properties/Properties.js
Normal file
14
frontend/src/pages/Properties/Properties.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const Properties = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Properties</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Property management coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Properties;
|
||||
17
frontend/src/pages/Properties/PropertyDetail.js
Normal file
17
frontend/src/pages/Properties/PropertyDetail.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
const PropertyDetail = () => {
|
||||
const { id } = useParams();
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Property Details</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Property {id} details coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyDetail;
|
||||
14
frontend/src/pages/Weather/Weather.js
Normal file
14
frontend/src/pages/Weather/Weather.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
|
||||
const Weather = () => {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900 mb-6">Weather</h1>
|
||||
<div className="card">
|
||||
<p className="text-gray-600">Weather information coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Weather;
|
||||
177
frontend/src/services/api.js
Normal file
177
frontend/src/services/api.js
Normal file
@@ -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;
|
||||
76
frontend/tailwind.config.js
Normal file
76
frontend/tailwind.config.js
Normal file
@@ -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'),
|
||||
],
|
||||
}
|
||||
151
nginx/nginx.conf
Normal file
151
nginx/nginx.conf
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user