Files
turftracker/backend/src/routes/weather.js
2025-08-21 07:06:36 -05:00

454 lines
14 KiB
JavaScript

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;