diff --git a/Cargo.lock b/Cargo.lock index 001a853..cdbd802 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -345,6 +345,7 @@ dependencies = [ "chrono", "clap", "dotenvy", + "isocountry", "once_cell", "portpicker", "rand 0.8.5", @@ -1277,6 +1278,16 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "isocountry" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ea1dc4bf0fb4904ba83ffdb98af3d9c325274e92e6e295e4151e86c96363e04" +dependencies = [ + "serde", + "thiserror 1.0.69", +] + [[package]] name = "itoa" version = "1.0.15" diff --git a/Cargo.toml b/Cargo.toml index eb8f291..6439f18 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ base64 = "0.22" chrono = { version = "0.4", features = ["serde", "clock"] } clap = { version = "4.5", features = ["derive", "env"] } dotenvy = "0.15" +isocountry = "0.3" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "gzip"] } rpassword = "7.3" serde = { version = "1.0", features = ["derive"] } diff --git a/src/application/routes/cafes.rs b/src/application/routes/cafes.rs index 8ff8a40..6eae450 100644 --- a/src/application/routes/cafes.rs +++ b/src/application/routes/cafes.rs @@ -15,7 +15,7 @@ use crate::application::server::AppState; use crate::domain::cafes::{Cafe, CafeSortKey, NewCafe, UpdateCafe}; use crate::domain::ids::CafeId; use crate::domain::listing::{ListRequest, SortDirection}; -use crate::infrastructure::osm::{self, NearbyCafe}; +use crate::infrastructure::foursquare::{self, NearbyCafe}; use crate::presentation::web::templates::{CafeDetailTemplate, CafeListTemplate, CafesTemplate}; use crate::presentation::web::views::{CafeView, ListNavigator, Paginated}; @@ -209,9 +209,15 @@ pub(crate) async fn nearby_cafes( return Err(AppError::validation("q must be at least 2 characters").into()); } - let cafes = osm::search_nearby( + let api_key = state + .foursquare_api_key + .as_deref() + .ok_or_else(|| AppError::unexpected("Foursquare API key not configured"))?; + + let cafes = foursquare::search_nearby( &state.http_client, - &state.nominatim_url, + &state.foursquare_url, + api_key, query.lat, query.lng, q, diff --git a/src/application/server.rs b/src/application/server.rs index 8b1746f..af385d8 100644 --- a/src/application/server.rs +++ b/src/application/server.rs @@ -34,6 +34,7 @@ pub struct ServerConfig { pub admin_username: Option, pub openrouter_api_key: Option, pub openrouter_model: String, + pub foursquare_api_key: Option, } #[derive(Clone)] @@ -50,7 +51,8 @@ pub struct AppState { pub token_repo: Arc, pub session_repo: Arc, pub http_client: reqwest::Client, - pub nominatim_url: String, + pub foursquare_url: String, + pub foursquare_api_key: Option, pub openrouter_api_key: Option, pub openrouter_model: String, } @@ -70,7 +72,8 @@ impl AppState { token_repo: Arc, session_repo: Arc, http_client: reqwest::Client, - nominatim_url: String, + foursquare_url: String, + foursquare_api_key: Option, openrouter_api_key: Option, openrouter_model: String, ) -> Self { @@ -87,7 +90,8 @@ impl AppState { token_repo, session_repo, http_client, - nominatim_url, + foursquare_url, + foursquare_api_key, openrouter_api_key, openrouter_model, } @@ -135,7 +139,8 @@ pub async fn serve(config: ServerConfig) -> anyhow::Result<()> { token_repo, session_repo, reqwest::Client::new(), - crate::infrastructure::osm::NOMINATIM_SEARCH_URL.to_string(), + crate::infrastructure::foursquare::FOURSQUARE_SEARCH_URL.to_string(), + config.foursquare_api_key, config.openrouter_api_key, config.openrouter_model, ); diff --git a/src/infrastructure/foursquare.rs b/src/infrastructure/foursquare.rs new file mode 100644 index 0000000..3ff73a4 --- /dev/null +++ b/src/infrastructure/foursquare.rs @@ -0,0 +1,261 @@ +use std::time::Duration; + +use isocountry::CountryCode; +use serde::{Deserialize, Serialize}; + +use crate::application::errors::AppError; + +pub const FOURSQUARE_SEARCH_URL: &str = "https://places-api.foursquare.com/places/search"; +const USER_AGENT: &str = "Brewlog/1.0"; +const MAX_RESULTS: &str = "15"; +const RADIUS: &str = "5000"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const FIELDS: &str = "name,latitude,longitude,location,website,distance"; +const API_VERSION: &str = "2025-06-17"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NearbyCafe { + pub name: String, + pub latitude: f64, + pub longitude: f64, + pub city: String, + pub country: String, + pub website: Option, + pub distance_meters: u32, +} + +/// Searches for places matching `query` near the given coordinates via Foursquare. +pub async fn search_nearby( + client: &reqwest::Client, + base_url: &str, + api_key: &str, + lat: f64, + lng: f64, + query: &str, +) -> Result, AppError> { + let ll = format!("{lat},{lng}"); + + let response = client + .get(base_url) + .header("User-Agent", USER_AGENT) + .header("Accept", "application/json") + .header("Authorization", format!("Bearer {api_key}")) + .header("X-Places-Api-Version", API_VERSION) + .timeout(REQUEST_TIMEOUT) + .query(&[("query", query), ("limit", MAX_RESULTS), ("fields", FIELDS)]) + .query(&[("ll", ll.as_str()), ("radius", RADIUS)]) + .send() + .await + .map_err(|e| AppError::unexpected(format!("Foursquare search failed: {e}")))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "(unreadable body)".to_string()); + return Err(AppError::unexpected(format!( + "Foursquare returned status {status}: {body}" + ))); + } + + let result: FoursquareResponse = response + .json() + .await + .map_err(|e| AppError::unexpected(format!("Failed to parse Foursquare response: {e}")))?; + + let cafes = result + .results + .into_iter() + .filter_map(|place| { + if place.name.is_empty() { + return None; + } + + let place_lat = place.latitude?; + let place_lng = place.longitude?; + let place_location = place.location.unwrap_or_default(); + + let country = place_location + .country + .as_deref() + .map(country_name) + .unwrap_or_default(); + + let distance = place + .distance + .unwrap_or_else(|| haversine_distance(lat, lng, place_lat, place_lng) as u32); + + let website = place.website.filter(|w| !w.trim().is_empty()); + + Some(NearbyCafe { + name: place.name, + latitude: place_lat, + longitude: place_lng, + city: place_location.locality.unwrap_or_default(), + country, + website, + distance_meters: distance, + }) + }) + .collect(); + + Ok(cafes) +} + +/// Converts a 2-letter ISO 3166-1 alpha-2 country code to a full country name. +/// Falls back to the raw code if the lookup fails. +fn country_name(code: &str) -> String { + // Override verbose ISO 3166-1 names with common short forms + match code.to_ascii_uppercase().as_str() { + "GB" => "United Kingdom".to_string(), + "US" => "United States".to_string(), + "KR" => "South Korea".to_string(), + "KP" => "North Korea".to_string(), + "TW" => "Taiwan".to_string(), + "RU" => "Russia".to_string(), + "IR" => "Iran".to_string(), + "SY" => "Syria".to_string(), + "VE" => "Venezuela".to_string(), + "BO" => "Bolivia".to_string(), + "TZ" => "Tanzania".to_string(), + _ => CountryCode::for_alpha2_caseless(code) + .map_or_else(|_| code.to_string(), |cc| cc.name().to_string()), + } +} + +/// Haversine distance in meters between two lat/lng points. +fn haversine_distance(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 { + const R: f64 = 6_371_000.0; // Earth radius in meters + + let d_lat = (lat2 - lat1).to_radians(); + let d_lng = (lng2 - lng1).to_radians(); + + let a = (d_lat / 2.0).sin().powi(2) + + lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lng / 2.0).sin().powi(2); + + let c = 2.0 * a.sqrt().asin(); + R * c +} + +// --- Foursquare API types --- + +#[derive(Debug, Deserialize)] +struct FoursquareResponse { + results: Vec, +} + +#[derive(Debug, Deserialize)] +struct FoursquarePlace { + name: String, + #[serde(default)] + latitude: Option, + #[serde(default)] + longitude: Option, + #[serde(default)] + location: Option, + #[serde(default)] + website: Option, + #[serde(default)] + distance: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct FoursquareLocation { + locality: Option, + country: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn haversine_london_to_paris() { + // London (51.5074, -0.1278) to Paris (48.8566, 2.3522) ≈ 344 km + let dist = haversine_distance(51.5074, -0.1278, 48.8566, 2.3522); + let km = dist / 1000.0; + assert!((km - 344.0).abs() < 5.0, "Expected ~344 km, got {km:.1} km"); + } + + #[test] + fn haversine_same_point_is_zero() { + let dist = haversine_distance(51.5, -0.1, 51.5, -0.1); + assert!(dist.abs() < 0.01, "Expected 0, got {dist}"); + } + + #[test] + fn parse_foursquare_search_response() { + let json = r#"{ + "results": [ + { + "name": "Prufrock Coffee", + "latitude": 51.5246, + "longitude": -0.1098, + "location": { + "locality": "London", + "country": "GB" + }, + "website": "https://www.prufrockcoffee.com", + "distance": 2800 + }, + { + "name": "Department of Coffee", + "latitude": 51.5200, + "longitude": -0.1050, + "location": { + "locality": "London", + "country": "GB" + }, + "distance": 2500 + } + ] + }"#; + + let response: FoursquareResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.results.len(), 2); + + let first = &response.results[0]; + assert_eq!(first.name, "Prufrock Coffee"); + assert_eq!(first.latitude, Some(51.5246)); + assert_eq!( + first.location.as_ref().unwrap().locality.as_deref(), + Some("London") + ); + assert_eq!( + first.website.as_deref(), + Some("https://www.prufrockcoffee.com") + ); + assert_eq!(first.distance, Some(2800)); + + let second = &response.results[1]; + assert_eq!(second.name, "Department of Coffee"); + assert!(second.website.is_none()); + } + + #[test] + fn parse_foursquare_result_without_location() { + let json = r#"{ + "results": [{ + "name": "Café de Flore", + "latitude": 48.8566, + "longitude": 2.3522, + "distance": 100 + }] + }"#; + + let response: FoursquareResponse = serde_json::from_str(json).unwrap(); + assert_eq!(response.results.len(), 1); + assert!(response.results[0].location.is_none()); + assert!(response.results[0].website.is_none()); + } + + #[test] + fn country_code_to_name() { + assert_eq!(country_name("GB"), "United Kingdom"); + assert_eq!(country_name("US"), "United States"); + assert_eq!(country_name("FR"), "France"); + // Unknown codes fall back to raw value + assert_eq!(country_name("XX"), "XX"); + } +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 9acbc47..eef1141 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -3,5 +3,5 @@ pub mod auth; pub mod backup; pub mod client; pub mod database; -pub mod osm; +pub mod foursquare; pub mod repositories; diff --git a/src/infrastructure/osm.rs b/src/infrastructure/osm.rs deleted file mode 100644 index 09ef490..0000000 --- a/src/infrastructure/osm.rs +++ /dev/null @@ -1,305 +0,0 @@ -use std::time::Duration; - -use serde::{Deserialize, Serialize}; - -use crate::application::errors::AppError; - -pub const NOMINATIM_SEARCH_URL: &str = "https://nominatim.openstreetmap.org/search"; -const USER_AGENT: &str = "Brewlog/1.0"; -const MAX_RESULTS: &str = "8"; -const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); -/// Viewbox half-size in degrees (~11 km at equator, tighter at higher latitudes). -const VIEWBOX_DELTA: f64 = 0.1; - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NearbyCafe { - pub name: String, - pub latitude: f64, - pub longitude: f64, - pub city: String, - pub country: String, - pub website: Option, - pub distance_meters: u32, -} - -/// Searches for places matching `query` near the given coordinates via Nominatim. -/// Results are biased towards (but not restricted to) the user's location. -pub async fn search_nearby( - client: &reqwest::Client, - base_url: &str, - lat: f64, - lng: f64, - query: &str, -) -> Result, AppError> { - let viewbox = format!( - "{},{},{},{}", - lng - VIEWBOX_DELTA, - lat + VIEWBOX_DELTA, - lng + VIEWBOX_DELTA, - lat - VIEWBOX_DELTA, - ); - - let response = client - .get(base_url) - .header("User-Agent", USER_AGENT) - .timeout(REQUEST_TIMEOUT) - .query(&[ - ("q", query), - ("format", "json"), - ("limit", MAX_RESULTS), - ("addressdetails", "1"), - ("namedetails", "1"), - ("extratags", "1"), - ("viewbox", &viewbox), - ("bounded", "0"), - ]) - .send() - .await - .map_err(|e| AppError::unexpected(format!("Nominatim search failed: {e}")))?; - - if !response.status().is_success() { - return Err(AppError::unexpected(format!( - "Nominatim returned status {}", - response.status() - ))); - } - - let results: Vec = response - .json() - .await - .map_err(|e| AppError::unexpected(format!("Failed to parse Nominatim response: {e}")))?; - - let cafes = results - .into_iter() - .filter_map(|r| { - let result_lat: f64 = r.lat.parse().ok()?; - let result_lng: f64 = r.lon.parse().ok()?; - - let name = r.namedetails.and_then(|nd| nd.name).unwrap_or_else(|| { - // Fall back to text before first comma in display_name - r.display_name - .split(',') - .next() - .unwrap_or(&r.display_name) - .trim() - .to_string() - }); - - if name.is_empty() { - return None; - } - - let address = r.address.unwrap_or_default(); - - let city = address - .city - .or(address.town) - .or(address.village) - .unwrap_or_default(); - - let website = r - .extratags - .and_then(|tags| { - tags.website - .or(tags.contact_website) - .or(tags.url) - .or(tags.contact_url) - .or(tags.brand_website) - }) - .filter(|w| !w.trim().is_empty()); - - let distance = haversine_distance(lat, lng, result_lat, result_lng); - - Some(NearbyCafe { - name, - latitude: result_lat, - longitude: result_lng, - city, - country: address.country.unwrap_or_default(), - website, - distance_meters: distance as u32, - }) - }) - .collect(); - - Ok(cafes) -} - -/// Haversine distance in meters between two lat/lng points. -fn haversine_distance(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 { - const R: f64 = 6_371_000.0; // Earth radius in meters - - let d_lat = (lat2 - lat1).to_radians(); - let d_lng = (lng2 - lng1).to_radians(); - - let a = (d_lat / 2.0).sin().powi(2) - + lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lng / 2.0).sin().powi(2); - - let c = 2.0 * a.sqrt().asin(); - R * c -} - -// --- Nominatim types --- - -#[derive(Debug, Deserialize)] -struct NominatimSearchResult { - lat: String, - lon: String, - display_name: String, - #[serde(default)] - namedetails: Option, - #[serde(default)] - address: Option, - #[serde(default)] - extratags: Option, -} - -#[derive(Debug, Deserialize)] -struct NominatimNameDetails { - name: Option, -} - -#[derive(Debug, Default, Deserialize)] -struct NominatimAddress { - city: Option, - town: Option, - village: Option, - country: Option, -} - -#[derive(Debug, Deserialize)] -struct NominatimExtraTags { - website: Option, - #[serde(rename = "contact:website")] - contact_website: Option, - url: Option, - #[serde(rename = "contact:url")] - contact_url: Option, - #[serde(rename = "brand:website")] - brand_website: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn haversine_london_to_paris() { - // London (51.5074, -0.1278) to Paris (48.8566, 2.3522) ≈ 344 km - let dist = haversine_distance(51.5074, -0.1278, 48.8566, 2.3522); - let km = dist / 1000.0; - assert!((km - 344.0).abs() < 5.0, "Expected ~344 km, got {km:.1} km"); - } - - #[test] - fn haversine_same_point_is_zero() { - let dist = haversine_distance(51.5, -0.1, 51.5, -0.1); - assert!(dist.abs() < 0.01, "Expected 0, got {dist}"); - } - - #[test] - fn parse_nominatim_search_response() { - let json = r#"[ - { - "place_id": 123, - "lat": "51.5246", - "lon": "-0.1098", - "display_name": "Prufrock Coffee, Leather Lane, London, England, United Kingdom", - "namedetails": { "name": "Prufrock Coffee" }, - "address": { - "cafe": "Prufrock Coffee", - "road": "Leather Lane", - "city": "London", - "state": "England", - "country": "United Kingdom", - "country_code": "gb" - }, - "extratags": { - "website": "https://www.prufrockcoffee.com" - } - }, - { - "place_id": 456, - "lat": "51.4543", - "lon": "-2.5930", - "display_name": "Full Court Press, Broad Street, Bristol, England, United Kingdom", - "namedetails": { "name": "Full Court Press" }, - "address": { - "town": "Bristol", - "country": "United Kingdom" - } - } - ]"#; - - let results: Vec = serde_json::from_str(json).unwrap(); - assert_eq!(results.len(), 2); - - let first = &results[0]; - assert_eq!(first.lat, "51.5246"); - assert_eq!( - first.namedetails.as_ref().unwrap().name.as_deref(), - Some("Prufrock Coffee") - ); - assert_eq!( - first.address.as_ref().unwrap().city.as_deref(), - Some("London") - ); - assert_eq!( - first.extratags.as_ref().unwrap().website.as_deref(), - Some("https://www.prufrockcoffee.com") - ); - - // Second result uses town fallback, no extratags - let second = &results[1]; - assert!(second.address.as_ref().unwrap().city.is_none()); - assert_eq!( - second.address.as_ref().unwrap().town.as_deref(), - Some("Bristol") - ); - assert!(second.extratags.is_none()); - } - - #[test] - fn parse_nominatim_result_without_namedetails() { - let json = r#"[{ - "place_id": 789, - "lat": "48.8566", - "lon": "2.3522", - "display_name": "Café de Flore, Boulevard Saint-Germain, Paris, France", - "address": { - "city": "Paris", - "country": "France" - } - }]"#; - - let results: Vec = serde_json::from_str(json).unwrap(); - assert_eq!(results.len(), 1); - - // Without namedetails, should fall back to display_name prefix - assert!(results[0].namedetails.is_none()); - assert_eq!( - results[0].display_name.split(',').next().unwrap().trim(), - "Café de Flore" - ); - } - - #[test] - fn website_fallback_chain() { - // url tag should be used when website and contact:website are absent - let json = r#"[{ - "place_id": 101, - "lat": "51.5", - "lon": "-0.1", - "display_name": "Test Cafe, London, UK", - "namedetails": { "name": "Test Cafe" }, - "address": { "city": "London", "country": "United Kingdom" }, - "extratags": { "url": "https://testcafe.example.com" } - }]"#; - - let results: Vec = serde_json::from_str(json).unwrap(); - let tags = results[0].extratags.as_ref().unwrap(); - assert!(tags.website.is_none()); - assert!(tags.contact_website.is_none()); - assert_eq!(tags.url.as_deref(), Some("https://testcafe.example.com")); - } -} diff --git a/src/main.rs b/src/main.rs index 3cf5b33..102b6d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,6 +86,7 @@ async fn run_server(command: ServeCommand) -> Result<()> { admin_username: command.admin_username, openrouter_api_key: command.openrouter_api_key, openrouter_model: command.openrouter_model, + foursquare_api_key: command.foursquare_api_key, }; serve(config).await diff --git a/src/presentation/cli/mod.rs b/src/presentation/cli/mod.rs index 1e2a514..9ca3b42 100644 --- a/src/presentation/cli/mod.rs +++ b/src/presentation/cli/mod.rs @@ -124,6 +124,9 @@ pub struct ServeCommand { default_value = "openrouter/free" )] pub openrouter_model: String, + + #[arg(long, env = "BREWLOG_FOURSQUARE_API_KEY")] + pub foursquare_api_key: Option, } pub(crate) fn print_json(value: &T) -> anyhow::Result<()> diff --git a/templates/cafes.html b/templates/cafes.html index 3302a1b..b968108 100644 --- a/templates/cafes.html +++ b/templates/cafes.html @@ -3,14 +3,14 @@ {% block head %} {% if is_authenticated %}