// Weather location data access and form normalization. const { fetchPagedRows, validateMaxLength } = require('./utils'); const { fetchAppSettings } = require('./app-settings'); const NAME_MAX_LENGTH = 255; const LOCATION_MAX_LENGTH = 255; const TIMEZONE_MAX_LENGTH = 128; const PROVIDERS = ['open-meteo', 'pirate-weather']; const TEMPERATURE_UNITS = ['celsius', 'fahrenheit']; const WIND_UNITS = ['kmh', 'mph', 'ms']; const PRECIPITATION_UNITS = ['mm', 'inch']; async function fetchWeatherLocationSuggestions(query) { const search = validateMaxLength(query, LOCATION_MAX_LENGTH, 'Location search'); if (!search) { return []; } const response = await fetch('https://geocoding-api.open-meteo.com/v1/search?name=' + encodeURIComponent(search) + '&count=8&language=en&format=json', { headers: { Accept: 'application/json', 'User-Agent': 'Pulse Signage weather location lookup' } }); if (!response.ok) { throw new Error('Weather location lookup failed.'); } const data = await response.json(); return (Array.isArray(data.results) ? data.results : []).map(function (result) { return { label: [result.name, result.admin1, result.country].filter(Boolean).join(', '), latitude: Number(result.latitude), longitude: Number(result.longitude), timezone: String(result.timezone || '') }; }).filter(function (result) { return result.label && Number.isFinite(result.latitude) && Number.isFinite(result.longitude) && result.timezone; }); } function normalizeChoice(value, choices, fallback) { const normalized = String(value || '').trim().toLowerCase(); return choices.includes(normalized) ? normalized : fallback; } async function fetchWeatherLocationsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: 'SELECT id, name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, created_at, modified_at FROM i_weather_locations ORDER BY modified_at DESC, id DESC', countSql: 'SELECT COUNT(*) AS count FROM i_weather_locations', searchColumns: ['name', 'location_label', 'timezone', 'provider'], searchTerm: searchTerm, sortColumns: { name: 'name', location: 'location_label', provider: 'provider', interval: ['update_interval_value', 'update_interval_unit'], last_pulled: 'last_pulled_at', modified: 'modified_at' }, sortKey: sortKey, sortDirection: sortDirection, page: page, pageSize: pageSize }); return Object.assign({ weatherLocations: paged.rows }, paged); } async function fetchWeatherLocationsData(pool) { const [rows] = await pool.query('SELECT id, name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_json, created_at, modified_at FROM i_weather_locations ORDER BY modified_at DESC, id DESC'); return { weatherLocations: rows }; } async function fetchWeatherLocationById(pool, id) { const [rows] = await pool.query('SELECT id, name, location_label, latitude, longitude, timezone, provider, temperature_unit, wind_unit, precipitation_unit, enabled, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_json, created_at, modified_at, created_by, modified_by FROM i_weather_locations WHERE id = ?', [id]); return rows[0] || null; } async function fetchWeatherLocationForecast(pool, location) { const source = location || {}; const settings = await fetchAppSettings(pool); const latitude = Number(source.latitude); const longitude = Number(source.longitude); const temperatureUnit = source.temperature_unit === 'fahrenheit' ? 'fahrenheit' : 'celsius'; const windUnit = source.wind_unit === 'mph' ? 'mph' : source.wind_unit === 'ms' ? 'ms' : 'kmh'; const precipitationUnit = source.precipitation_unit === 'inch' ? 'inch' : 'mm'; let url; let headers = { Accept: 'application/json', 'User-Agent': 'Pulse Signage weather reader' }; if (source.provider === 'pirate-weather') { const apiKey = String(settings['weather.pirate_weather_api_key'] || '').trim(); if (!apiKey) throw new Error('Pirate Weather API key is not configured.'); url = 'https://api.pirateweather.net/forecast/' + encodeURIComponent(apiKey) + '/' + latitude + ',' + longitude + '?units=' + (temperatureUnit === 'fahrenheit' ? 'us' : 'si'); } else { const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), timezone: String(source.timezone || 'auto'), forecast_days: '7', forecast_hours: '24', current: 'temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,weather_code,wind_speed_10m,wind_direction_10m,uv_index,cloud_cover', hourly: 'temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,uv_index,cloud_cover', daily: 'weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max,precipitation_sum,wind_speed_10m_max,uv_index_max,cloud_cover_mean', temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit }); const apiKey = String(settings['weather.open_meteo_api_key'] || '').trim(); if (apiKey) params.set('apikey', apiKey); url = 'https://api.open-meteo.com/v1/forecast?' + params.toString(); } const response = await fetch(url, { headers: headers }); if (!response.ok) throw new Error('Weather provider returned HTTP ' + response.status + '.'); const snapshot = await response.json(); return { snapshot: snapshot, responseJson: JSON.stringify(snapshot), fetchedAt: new Date() }; } function buildWeatherLocationPayload(req, existingLocation) { const body = req && req.body ? req.body : {}; const fallback = existingLocation || {}; const name = validateMaxLength(body.name || fallback.name || '', NAME_MAX_LENGTH, 'Weather location name'); const locationLabel = validateMaxLength(body.location_label || fallback.location_label || '', LOCATION_MAX_LENGTH, 'Location label'); const latitude = Number(body.latitude !== undefined ? body.latitude : fallback.latitude); const longitude = Number(body.longitude !== undefined ? body.longitude : fallback.longitude); const timezone = validateMaxLength(body.timezone || fallback.timezone || '', TIMEZONE_MAX_LENGTH, 'Timezone'); const provider = normalizeChoice(body.provider || fallback.provider, PROVIDERS, 'open-meteo'); const temperatureUnit = normalizeChoice(body.temperature_unit || fallback.temperature_unit, TEMPERATURE_UNITS, 'celsius'); const windUnit = normalizeChoice(body.wind_unit || fallback.wind_unit, WIND_UNITS, 'kmh'); const precipitationUnit = normalizeChoice(body.precipitation_unit || fallback.precipitation_unit, PRECIPITATION_UNITS, 'mm'); const updateIntervalValue = Number(body.update_interval_value || fallback.update_interval_value || 30); const updateIntervalUnit = normalizeChoice(body.update_interval_unit || fallback.update_interval_unit, ['minutes', 'hours'], 'minutes'); if (!name || !locationLabel || !timezone) { const error = new Error('Name, location label, and timezone are required.'); error.statusCode = 400; throw error; } if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90 || !Number.isFinite(longitude) || longitude < -180 || longitude > 180) { const error = new Error('Latitude must be between -90 and 90, and longitude must be between -180 and 180.'); error.statusCode = 400; throw error; } if (!Number.isInteger(updateIntervalValue) || updateIntervalValue < 1 || updateIntervalValue > 1440) { const error = new Error('Update interval must be a whole number between 1 and 1440.'); error.statusCode = 400; throw error; } return { name, locationLabel, latitude, longitude, timezone, provider, temperatureUnit, windUnit, precipitationUnit, updateIntervalValue, updateIntervalUnit }; } module.exports = { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload, PROVIDERS, TEMPERATURE_UNITS, WIND_UNITS, PRECIPITATION_UNITS };