feat: implement external API integration with static tide tables (task 32)
- Add in-memory caching for external API responses (6-hour expiration) - Implement OpenWeatherMap integration for weather data - Create static tide tables for 22 Irish coastal stations - Add tide calculation based on semi-diurnal patterns and lunar cycles - Implement water quality, beach quality, and biodiversity placeholder endpoints - Add comprehensive API documentation (EXTERNAL_API_SETUP.md) - Fix frontend WaterQualityIndicator crash - Remove Redis dependency (using in-memory cache instead) - All external data endpoints functional and tested
This commit is contained in:
403
server/src/services/externalDataService.ts
Normal file
403
server/src/services/externalDataService.ts
Normal file
@@ -0,0 +1,403 @@
|
||||
import axios from 'axios';
|
||||
import { findNearestStation, calculateTideTimes } from '../data/tideTables';
|
||||
|
||||
// Cache expiration: 6 hours (in milliseconds)
|
||||
const CACHE_EXPIRATION = 6 * 60 * 60 * 1000;
|
||||
|
||||
// In-memory cache
|
||||
interface CacheEntry<T> {
|
||||
data: T;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const memoryCache = new Map<string, CacheEntry<any>>();
|
||||
|
||||
// Simple in-memory cache utilities
|
||||
const cache = {
|
||||
get<T>(key: string): T | null {
|
||||
const entry = memoryCache.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
// Check if expired
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
memoryCache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data as T;
|
||||
},
|
||||
|
||||
set(key: string, value: any, expirationMs: number): void {
|
||||
memoryCache.set(key, {
|
||||
data: value,
|
||||
expiresAt: Date.now() + expirationMs,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
// API Keys from environment
|
||||
const OPENWEATHER_API_KEY = process.env.OPENWEATHER_API_KEY;
|
||||
const STORMGLASS_API_KEY = process.env.STORMGLASS_API_KEY;
|
||||
|
||||
/**
|
||||
* Weather data interface
|
||||
*/
|
||||
export interface WeatherData {
|
||||
temperature: number;
|
||||
feelsLike: number;
|
||||
humidity: number;
|
||||
windSpeed: number;
|
||||
windDirection: number;
|
||||
description: string;
|
||||
icon: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tide data interface
|
||||
*/
|
||||
export interface TideData {
|
||||
extremes: Array<{
|
||||
type: 'High' | 'Low';
|
||||
time: string;
|
||||
height: number;
|
||||
}>;
|
||||
currentHeight?: number;
|
||||
nextTide?: {
|
||||
type: 'High' | 'Low';
|
||||
time: string;
|
||||
height: number;
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Water quality data interface
|
||||
*/
|
||||
export interface WaterQualityData {
|
||||
status: 'excellent' | 'good' | 'sufficient' | 'poor' | 'unknown';
|
||||
lastSampled?: string;
|
||||
parameters?: {
|
||||
ecoli?: number;
|
||||
enterococci?: number;
|
||||
};
|
||||
source: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Beach quality data interface
|
||||
*/
|
||||
export interface BeachQualityData {
|
||||
rating: 'blue-flag' | 'green-coast' | 'clean-beach' | 'none';
|
||||
awards: string[];
|
||||
facilities: string[];
|
||||
accessibility: string;
|
||||
lastUpdated?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get weather data from OpenWeatherMap API
|
||||
*/
|
||||
export const getWeatherData = async (
|
||||
latitude: number,
|
||||
longitude: number
|
||||
): Promise<WeatherData | null> => {
|
||||
const cacheKey = `weather:${latitude.toFixed(4)}:${longitude.toFixed(4)}`;
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
const cached = cache.get<WeatherData>(cacheKey);
|
||||
if (cached) {
|
||||
console.log('Weather data retrieved from cache');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Check if API key is configured
|
||||
if (!OPENWEATHER_API_KEY) {
|
||||
console.warn('OpenWeatherMap API key not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch from API
|
||||
const response = await axios.get(
|
||||
'https://api.openweathermap.org/data/2.5/weather',
|
||||
{
|
||||
params: {
|
||||
lat: latitude,
|
||||
lon: longitude,
|
||||
appid: OPENWEATHER_API_KEY,
|
||||
units: 'metric',
|
||||
},
|
||||
timeout: 5000,
|
||||
}
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
const weatherData: WeatherData = {
|
||||
temperature: data.main.temp,
|
||||
feelsLike: data.main.feels_like,
|
||||
humidity: data.main.humidity,
|
||||
windSpeed: data.wind.speed,
|
||||
windDirection: data.wind.deg,
|
||||
description: data.weather[0].description,
|
||||
icon: data.weather[0].icon,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
cache.set(cacheKey, weatherData, CACHE_EXPIRATION);
|
||||
|
||||
return weatherData;
|
||||
} catch (error) {
|
||||
console.error('Error fetching weather data:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get tide data using static tide tables for Irish coastal locations
|
||||
* Based on semi-diurnal tide patterns and nearest tide station
|
||||
*/
|
||||
export const getTideData = async (
|
||||
latitude: number,
|
||||
longitude: number
|
||||
): Promise<TideData | null> => {
|
||||
const cacheKey = `tide:${latitude.toFixed(4)}:${longitude.toFixed(4)}`;
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
const cached = cache.get<TideData>(cacheKey);
|
||||
if (cached) {
|
||||
console.log('Tide data retrieved from cache');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Find nearest tide station
|
||||
const station = findNearestStation(latitude, longitude);
|
||||
console.log(`Using tide station: ${station.name} (${station.region})`);
|
||||
|
||||
// Calculate tide times for today and tomorrow
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
|
||||
const todayTides = calculateTideTimes(station, today);
|
||||
const tomorrowTides = calculateTideTimes(station, tomorrow);
|
||||
|
||||
// Combine and filter to next 24 hours
|
||||
const allTides = [...todayTides, ...tomorrowTides];
|
||||
const now = Date.now();
|
||||
const next24Hours = now + 24 * 60 * 60 * 1000;
|
||||
|
||||
const extremes = allTides
|
||||
.filter((tide) => {
|
||||
const tideTime = tide.time.getTime();
|
||||
return tideTime >= now && tideTime <= next24Hours;
|
||||
})
|
||||
.map((tide) => ({
|
||||
type: tide.type,
|
||||
time: tide.time.toISOString(),
|
||||
height: tide.height,
|
||||
}));
|
||||
|
||||
// Find next tide
|
||||
const nextTide = extremes.length > 0 ? extremes[0] : undefined;
|
||||
|
||||
const tideData: TideData = {
|
||||
extremes,
|
||||
nextTide,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
cache.set(cacheKey, tideData, CACHE_EXPIRATION);
|
||||
|
||||
return tideData;
|
||||
} catch (error) {
|
||||
console.error('Error calculating tide data:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get water quality data from Irish EPA
|
||||
* Note: This is a simplified implementation. The actual EPA API may require
|
||||
* different endpoints or authentication.
|
||||
*/
|
||||
export const getWaterQualityData = async (
|
||||
latitude: number,
|
||||
longitude: number
|
||||
): Promise<WaterQualityData | null> => {
|
||||
const cacheKey = `water-quality:${latitude.toFixed(4)}:${longitude.toFixed(4)}`;
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
const cached = cache.get<WaterQualityData>(cacheKey);
|
||||
if (cached) {
|
||||
console.log('Water quality data retrieved from cache');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Note: EPA API integration would go here
|
||||
// For now, return mock data structure
|
||||
// In production, you would integrate with:
|
||||
// https://gis.epa.ie/EPAMaps/
|
||||
// or EPA's open data portal
|
||||
|
||||
console.warn('EPA water quality API not fully implemented - returning placeholder');
|
||||
|
||||
const waterQualityData: WaterQualityData = {
|
||||
status: 'unknown',
|
||||
source: 'EPA Ireland',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
cache.set(cacheKey, waterQualityData, CACHE_EXPIRATION);
|
||||
|
||||
return waterQualityData;
|
||||
} catch (error) {
|
||||
console.error('Error fetching water quality data:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get beach quality rating data
|
||||
* This would integrate with Blue Flag, Green Coast Award databases
|
||||
*/
|
||||
export const getBeachQualityData = async (
|
||||
latitude: number,
|
||||
longitude: number
|
||||
): Promise<BeachQualityData | null> => {
|
||||
const cacheKey = `beach-quality:${latitude.toFixed(4)}:${longitude.toFixed(4)}`;
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
const cached = cache.get<BeachQualityData>(cacheKey);
|
||||
if (cached) {
|
||||
console.log('Beach quality data retrieved from cache');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Note: Blue Flag / Green Coast API integration would go here
|
||||
// For now, return mock data structure
|
||||
// In production, you would integrate with:
|
||||
// - Blue Flag Ireland database
|
||||
// - Green Coast Award database
|
||||
// - An Taisce beach data
|
||||
|
||||
console.warn('Beach quality API not fully implemented - returning placeholder');
|
||||
|
||||
const beachQualityData: BeachQualityData = {
|
||||
rating: 'none',
|
||||
awards: [],
|
||||
facilities: [],
|
||||
accessibility: 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await cache.set(cacheKey, beachQualityData, CACHE_EXPIRATION);
|
||||
|
||||
return beachQualityData;
|
||||
} catch (error) {
|
||||
console.error('Error fetching beach quality data:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Biodiversity data interface
|
||||
*/
|
||||
export interface BiodiversityData {
|
||||
species: Array<{
|
||||
name: string;
|
||||
scientificName: string;
|
||||
category: string;
|
||||
}>;
|
||||
marineProtectedArea: boolean;
|
||||
habitatTypes: string[];
|
||||
conservationStatus?: string;
|
||||
source: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get marine biodiversity data for a location
|
||||
* This would integrate with GBIF, Marine Institute Ireland, or similar databases
|
||||
*/
|
||||
export const getBiodiversityData = async (
|
||||
latitude: number,
|
||||
longitude: number
|
||||
): Promise<BiodiversityData | null> => {
|
||||
const cacheKey = `biodiversity:${latitude.toFixed(4)}:${longitude.toFixed(4)}`;
|
||||
|
||||
try {
|
||||
// Check cache first
|
||||
const cached = await cache.get<BiodiversityData>(cacheKey);
|
||||
if (cached) {
|
||||
console.log('Biodiversity data retrieved from cache');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Note: GBIF or Marine Institute API integration would go here
|
||||
// For now, return mock data structure
|
||||
// In production, you would integrate with:
|
||||
// - GBIF (Global Biodiversity Information Facility)
|
||||
// - Marine Institute Ireland
|
||||
// - National Biodiversity Data Centre
|
||||
|
||||
console.warn('Biodiversity API not fully implemented - returning placeholder');
|
||||
|
||||
const biodiversityData: BiodiversityData = {
|
||||
species: [],
|
||||
marineProtectedArea: false,
|
||||
habitatTypes: [],
|
||||
source: 'Placeholder',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
await cache.set(cacheKey, biodiversityData, CACHE_EXPIRATION);
|
||||
|
||||
return biodiversityData;
|
||||
} catch (error) {
|
||||
console.error('Error fetching biodiversity data:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all environmental data for a location
|
||||
*/
|
||||
export const getAllEnvironmentalData = async (
|
||||
latitude: number,
|
||||
longitude: number
|
||||
) => {
|
||||
try {
|
||||
// Fetch all data in parallel
|
||||
const [weather, tides, waterQuality, beachQuality, biodiversity] = await Promise.all([
|
||||
getWeatherData(latitude, longitude),
|
||||
getTideData(latitude, longitude),
|
||||
getWaterQualityData(latitude, longitude),
|
||||
getBeachQualityData(latitude, longitude),
|
||||
getBiodiversityData(latitude, longitude),
|
||||
]);
|
||||
|
||||
return {
|
||||
weather,
|
||||
tides,
|
||||
waterQuality,
|
||||
beachQuality,
|
||||
biodiversity,
|
||||
location: { latitude, longitude },
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error fetching environmental data:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user