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