Initial Claude Run

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

27
.env.example Normal file
View 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
View File

@@ -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
View File

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

28
backend/healthcheck.js Normal file
View File

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

43
backend/package.json Normal file
View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

245
database/init.sql Normal file
View 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
View 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
View File

@@ -0,0 +1,31 @@
FROM node:18-alpine
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci
# 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
View 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"
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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