+1
-1
src/errors.rs
+1
-1
src/errors.rs
-1
src/http/handle_create_event.rs
-1
src/http/handle_create_event.rs
···
39
39
use crate::http::middleware_i18n::Language;
40
40
use crate::http::timezones::supported_timezones;
41
41
use crate::http::utils::url_from_aturi;
42
-
use crate::services::{NominatimClient, AddressGeocodingStrategies, AddressComponents};
43
42
use crate::services::nominatim_client::GeoExt;
44
43
use crate::storage::event::event_insert;
45
44
+10
-11
src/http/handle_event_location_venue.rs
+10
-11
src/http/handle_event_location_venue.rs
···
4
4
//! while maintaining full compatibility with existing location workflows.
5
5
6
6
use axum::{
7
-
extract::{Path, Query, State},
7
+
extract::{Query, State},
8
8
http::StatusCode,
9
9
response::{Html, IntoResponse, Json},
10
10
};
···
649
649
));
650
650
651
651
// Add coordinates if available (extract from Geo enum)
652
-
if let crate::atproto::lexicon::community::lexicon::location::Geo::Current { latitude, longitude, .. } = &venue.geo {
653
-
html.push_str(&format!(
654
-
r#"<input type="hidden" name="latitude" value="{}" hx-swap-oob="true">"#,
655
-
latitude
656
-
));
657
-
html.push_str(&format!(
658
-
r#"<input type="hidden" name="longitude" value="{}" hx-swap-oob="true">"#,
659
-
longitude
660
-
));
661
-
}
652
+
let crate::atproto::lexicon::community::lexicon::location::Geo::Current { latitude, longitude, .. } = &venue.geo;
653
+
html.push_str(&format!(
654
+
r#"<input type="hidden" name="latitude" value="{}" hx-swap-oob="true">"#,
655
+
latitude
656
+
));
657
+
html.push_str(&format!(
658
+
r#"<input type="hidden" name="longitude" value="{}" hx-swap-oob="true">"#,
659
+
longitude
660
+
));
662
661
663
662
// Clear the venue search suggestions
664
663
html.push_str(r#"<div id="venue-suggestions" hx-swap-oob="innerHTML"></div>"#);
+1
-2
src/services/address_geocoding_strategies.rs
+1
-2
src/services/address_geocoding_strategies.rs
···
18
18
//! ```
19
19
20
20
use anyhow::Result;
21
-
use crate::services::nominatim_client::{NominatimClient, NominatimSearchResult, NominatimError};
22
-
use crate::atproto::lexicon::community::lexicon::location::Address;
21
+
use crate::services::nominatim_client::{NominatimClient, NominatimSearchResult};
23
22
use tracing;
24
23
25
24
/// Address geocoding strategies with progressive fallback
-190
src/services/geocoding.rs
-190
src/services/geocoding.rs
···
1
-
use anyhow::Result;
2
-
use serde::{Deserialize, Serialize};
3
-
use reqwest::Client;
4
-
use std::time::Duration;
5
-
6
-
/// Legacy geocoding result - DEPRECATED
7
-
/// Use the new NominatimClient in nominatim_client.rs for new implementations
8
-
#[derive(Debug, Clone, Serialize, Deserialize)]
9
-
#[deprecated(note = "Use NominatimClient instead for lexicon-compatible venue data")]
10
-
pub struct GeocodingResult {
11
-
pub latitude: f64,
12
-
pub longitude: f64,
13
-
pub display_name: String,
14
-
}
15
-
16
-
/// Legacy geocoding service - DEPRECATED
17
-
/// Use the new NominatimClient in nominatim_client.rs for new implementations
18
-
#[deprecated(note = "Use NominatimClient instead for lexicon-compatible venue data")]
19
-
pub struct GeocodingService {
20
-
client: Client,
21
-
}
22
-
23
-
impl GeocodingService {
24
-
pub fn new() -> Self {
25
-
let client = Client::builder()
26
-
.timeout(Duration::from_secs(10))
27
-
.user_agent("Smokesignal-Events/1.0")
28
-
.build()
29
-
.unwrap_or_default();
30
-
31
-
Self { client }
32
-
}
33
-
34
-
/// Geocode an address string using multiple strategies
35
-
pub async fn geocode_address(&self, address: &str) -> Result<GeocodingResult> {
36
-
// Strategy 1: Try exact address
37
-
if let Ok(result) = self.geocode_exact(address).await {
38
-
return Ok(result);
39
-
}
40
-
41
-
// Strategy 2: Try simplified address (remove building numbers, etc.)
42
-
let simplified = self.simplify_address(address);
43
-
if simplified != address {
44
-
if let Ok(result) = self.geocode_exact(&simplified).await {
45
-
return Ok(result);
46
-
}
47
-
}
48
-
49
-
// Strategy 3: Try city/region only
50
-
let city_only = self.extract_city_from_address(address);
51
-
if !city_only.is_empty() && city_only != address {
52
-
if let Ok(result) = self.geocode_exact(&city_only).await {
53
-
return Ok(result);
54
-
}
55
-
}
56
-
57
-
Err(anyhow::anyhow!("Unable to geocode address: {}", address))
58
-
}
59
-
60
-
/// Perform exact geocoding using Nominatim
61
-
async fn geocode_exact(&self, address: &str) -> Result<GeocodingResult> {
62
-
let url = format!(
63
-
"https://localhost:8080/search?format=json&q={}&limit=1&addressdetails=1&accept-language=fr",
64
-
urlencoding::encode(address)
65
-
);
66
-
67
-
let response = self.client.get(&url).send().await?;
68
-
let data: Vec<NominatimResult> = response.json().await?;
69
-
70
-
if let Some(result) = data.first() {
71
-
Ok(GeocodingResult {
72
-
latitude: result.lat.parse()?,
73
-
longitude: result.lon.parse()?,
74
-
display_name: result.display_name.clone(),
75
-
})
76
-
} else {
77
-
Err(anyhow::anyhow!("No results found"))
78
-
}
79
-
}
80
-
81
-
/// Simplify address by removing only very specific details, preserving most address components
82
-
fn simplify_address(&self, address: &str) -> String {
83
-
let mut simplified = address.to_string();
84
-
85
-
// Remove house/building numbers at the beginning of the address
86
-
simplified = regex::Regex::new(r"^\d+(-\d+)?\s+")
87
-
.unwrap()
88
-
.replace(&simplified, "")
89
-
.to_string();
90
-
91
-
// Remove apartment/suite numbers (e.g., "Apt 123", "Suite 456") - these are too specific
92
-
// Updated regex to be case-insensitive and handle more variations
93
-
simplified = regex::Regex::new(r"(?i),?\s*(apt|apartment|suite|unit|#)\s*\d+\w*")
94
-
.unwrap()
95
-
.replace_all(&simplified, "")
96
-
.to_string();
97
-
98
-
// Remove postal codes (various formats) - often too specific for initial geocoding
99
-
simplified = regex::Regex::new(r"\b[A-Z]\d[A-Z]\s*\d[A-Z]\d\b")
100
-
.unwrap()
101
-
.replace_all(&simplified, "")
102
-
.to_string(); // Canadian postal codes
103
-
simplified = regex::Regex::new(r"\b\d{5}(-\d{4})?\b")
104
-
.unwrap()
105
-
.replace_all(&simplified, "")
106
-
.to_string(); // US ZIP codes
107
-
108
-
// Clean up extra whitespace and commas
109
-
simplified = regex::Regex::new(r"\s*,\s*,\s*")
110
-
.unwrap()
111
-
.replace_all(&simplified, ", ")
112
-
.to_string();
113
-
simplified = regex::Regex::new(r"\s+")
114
-
.unwrap()
115
-
.replace_all(&simplified, " ")
116
-
.trim()
117
-
.to_string();
118
-
119
-
simplified
120
-
}
121
-
122
-
/// Extract city/region from address for fallback geocoding
123
-
fn extract_city_from_address(&self, address: &str) -> String {
124
-
// Look for common patterns to extract city/province
125
-
let parts: Vec<&str> = address.split(',').collect();
126
-
127
-
if parts.len() >= 2 {
128
-
// Strategy: Skip the first part (likely street address) and take city/region/country components
129
-
// For "123 Main St, Montreal, Quebec, Canada" -> take last 3: "Montreal, Quebec, Canada"
130
-
// For "Complex Address, Montreal, QC" -> take last 2: "Montreal, QC"
131
-
132
-
let remaining_parts: Vec<&str> = parts.iter()
133
-
.skip(1) // Always skip the first part (street address)
134
-
.map(|s| s.trim())
135
-
.filter(|s| !s.is_empty())
136
-
.collect();
137
-
138
-
remaining_parts.join(", ")
139
-
} else {
140
-
// Fallback: try to extract just words (remove numbers and special chars)
141
-
regex::Regex::new(r"[^\w\s,]")
142
-
.unwrap()
143
-
.replace_all(address, "")
144
-
.trim()
145
-
.to_string()
146
-
}
147
-
}
148
-
}
149
-
150
-
#[derive(Debug, Deserialize)]
151
-
struct NominatimResult {
152
-
lat: String,
153
-
lon: String,
154
-
display_name: String,
155
-
}
156
-
157
-
#[cfg(test)]
158
-
mod tests {
159
-
use super::*;
160
-
161
-
#[test]
162
-
fn test_simplify_address() {
163
-
let service = GeocodingService::new();
164
-
165
-
assert_eq!(
166
-
service.simplify_address("123 Main St, Apt 456, Montreal, QC H1A 1A1"),
167
-
"Main St, Montreal, QC"
168
-
);
169
-
170
-
assert_eq!(
171
-
service.simplify_address("1000-1010 Sherbrooke St, Montreal, QC"),
172
-
"Sherbrooke St, Montreal, QC"
173
-
);
174
-
}
175
-
176
-
#[test]
177
-
fn test_extract_city() {
178
-
let service = GeocodingService::new();
179
-
180
-
assert_eq!(
181
-
service.extract_city_from_address("123 Main St, Montreal, Quebec, Canada"),
182
-
"Montreal, Quebec, Canada"
183
-
);
184
-
185
-
assert_eq!(
186
-
service.extract_city_from_address("Complex Address, Montreal, QC"),
187
-
"Montreal, QC"
188
-
);
189
-
}
190
-
}
+1
-3
src/services/mod.rs
+1
-3
src/services/mod.rs
···
1
-
pub mod geocoding;
2
1
pub mod nominatim_client;
3
2
pub mod address_geocoding_strategies;
4
3
pub mod venues;
···
7
6
#[cfg(test)]
8
7
mod nominatim_client_tests;
9
8
10
-
pub use geocoding::{GeocodingService, GeocodingResult};
11
9
pub use nominatim_client::{NominatimClient, NominatimSearchResult, VenueMetadata, BilingualNames};
12
10
pub use address_geocoding_strategies::{AddressGeocodingStrategies, AddressComponents, GeocodingStrategyResult};
13
11
pub use venues::{
14
12
VenueSearchService, VenueSearchRequest, VenueSearchResponse, VenueNearbyRequest,
15
-
VenueSearchResult, VenueDetails, VenueCategory, SearchRadius, handle_venue_search,
13
+
VenueSearchResult, VenueDetails, VenueCategory, handle_venue_search,
16
14
handle_venue_nearby, handle_venue_enrich, handle_venue_suggest
17
15
};
18
16
pub use events::{EventVenueIntegrationService, VenueIntegrationError};
+6
src/services/nominatim_client.rs
+6
src/services/nominatim_client.rs
···
93
93
#[derive(Debug, Deserialize)]
94
94
struct NominatimApiResponse {
95
95
place_id: Option<u64>,
96
+
#[allow(dead_code)]
96
97
licence: Option<String>,
97
98
osm_type: Option<String>,
98
99
osm_id: Option<u64>,
···
104
105
type_: Option<String>,
105
106
place_rank: Option<u32>,
106
107
importance: Option<f64>,
108
+
#[allow(dead_code)]
107
109
addresstype: Option<String>,
108
110
name: Option<String>,
109
111
display_name: String,
···
116
118
struct NominatimAddress {
117
119
house_number: Option<String>,
118
120
road: Option<String>,
121
+
#[allow(dead_code)]
119
122
neighbourhood: Option<String>,
123
+
#[allow(dead_code)]
120
124
suburb: Option<String>,
121
125
city: Option<String>,
122
126
town: Option<String>,
123
127
village: Option<String>,
124
128
municipality: Option<String>,
129
+
#[allow(dead_code)]
125
130
county: Option<String>,
126
131
state_district: Option<String>,
127
132
state: Option<String>,
128
133
region: Option<String>,
129
134
postcode: Option<String>,
130
135
country: Option<String>,
136
+
#[allow(dead_code)]
131
137
country_code: Option<String>,
132
138
}
133
139
-1
src/services/venues/venue_cache.rs
-1
src/services/venues/venue_cache.rs
···
18
18
/// Cache TTL constants
19
19
const VENUE_CACHE_TTL_SECS: u64 = 604800; // 7 days for venue enhancement data
20
20
const SEARCH_CACHE_TTL_SECS: u64 = 86400; // 24 hours for search results
21
-
const SUGGESTION_CACHE_TTL_SECS: u64 = 43200; // 12 hours for autocomplete suggestions
22
21
23
22
/// Cache key prefixes for different data types
24
23
const CACHE_PREFIX_VENUE_ENHANCEMENT: &str = "venue:enhanced";
-9
src/services/venues/venue_endpoints.rs
-9
src/services/venues/venue_endpoints.rs
···
118
118
limit: Option<usize>,
119
119
}
120
120
121
-
/// Path parameters for venue enrichment endpoint
122
-
#[derive(Debug, Deserialize)]
123
-
pub struct VenueEnrichParams {
124
-
/// Latitude for venue enhancement lookup
125
-
lat: f64,
126
-
/// Longitude for venue enhancement lookup
127
-
lng: f64,
128
-
}
129
-
130
121
/// GET /api/venues/search - Text-based venue search
131
122
pub async fn handle_venue_search(
132
123
State(web_context): State<WebContext>,
+2
-9
src/services/venues/venue_search.rs
+2
-9
src/services/venues/venue_search.rs
···
14
14
use crate::services::nominatim_client::{NominatimClient, NominatimSearchResult};
15
15
use super::venue_types::{
16
16
VenueSearchRequest, VenueNearbyRequest, VenueSearchResponse, VenueSearchResult,
17
-
VenueDetails, VenueQuality, SearchRadius
17
+
VenueDetails, VenueQuality
18
18
};
19
19
use super::venue_cache::{VenueCacheManager, VenueCacheError};
20
20
···
506
506
}
507
507
}
508
508
509
-
#[tokio::test]
510
-
async fn test_search_radius_validation() {
511
-
let valid_radius = SearchRadius::new(1000);
512
-
assert!(valid_radius.is_ok());
513
-
514
-
let invalid_radius = SearchRadius::new(50);
515
-
assert!(invalid_radius.is_err());
516
-
}
509
+
517
510
518
511
#[test]
519
512
fn test_coordinate_validation() {
+6
-65
src/storage/event.rs
+6
-65
src/storage/event.rs
···
10
10
use crate::atproto::lexicon::community::lexicon::calendar::rsvp::{
11
11
Rsvp as RsvpLexicon, RsvpStatus as RsvpStatusLexicon,
12
12
};
13
-
use crate::services::GeocodingService;
14
13
15
14
use super::errors::StorageError;
16
15
use super::StoragePool;
···
325
324
}
326
325
}
327
326
328
-
// Helper function to try geocoding an event record if it needs it
327
+
// Helper function to extract coordinates from event record without geocoding
328
+
// Geocoding should be handled at the API level using the venue services
329
329
async fn try_geocode_event_record<T: serde::Serialize>(
330
330
record: &T,
331
331
) -> (Option<(f64, f64)>, serde_json::Value) {
332
332
// Try to parse the event record
333
-
if let Ok(mut event_record) = serde_json::from_str::<EventLexicon>(&serde_json::to_string(record).unwrap_or_default()) {
334
-
match &mut event_record {
333
+
if let Ok(event_record) = serde_json::from_str::<EventLexicon>(&serde_json::to_string(record).unwrap_or_default()) {
334
+
match &event_record {
335
335
EventLexicon::Current { locations, .. } => {
336
-
// First, try to extract existing coordinates
336
+
// Extract existing coordinates only - no geocoding performed
337
337
let existing_coords = extract_coordinates_from_locations(locations);
338
-
339
-
if existing_coords.is_some() {
340
-
// Already has coordinates, use as-is
341
-
(existing_coords, json!(record))
342
-
} else {
343
-
// No coordinates found, try to geocode address-only locations
344
-
let mut updated_locations = locations.clone();
345
-
let mut found_coordinates = None;
346
-
347
-
// Look for address-only locations to geocode
348
-
for location in locations.iter() {
349
-
if let crate::atproto::lexicon::community::lexicon::calendar::event::EventLocation::Address(address) = location {
350
-
// Format the address for geocoding
351
-
let address_string = format_address(address);
352
-
if !address_string.trim().is_empty() {
353
-
// Attempt to geocode the address
354
-
let geocoding_service = GeocodingService::new();
355
-
if let Ok(geocoding_result) = geocoding_service.geocode_address(&address_string).await {
356
-
// Create a Geo location with the coordinates
357
-
let geo_location = crate::atproto::lexicon::community::lexicon::location::Geo::Current {
358
-
latitude: geocoding_result.latitude.to_string(),
359
-
longitude: geocoding_result.longitude.to_string(),
360
-
name: Some(geocoding_result.display_name),
361
-
};
362
-
363
-
// Add the geocoded location to the locations list
364
-
updated_locations.push(crate::atproto::lexicon::community::lexicon::calendar::event::EventLocation::Geo(geo_location));
365
-
366
-
// Use these coordinates for spatial indexing
367
-
found_coordinates = Some((geocoding_result.latitude, geocoding_result.longitude));
368
-
break; // Only geocode the first address found
369
-
}
370
-
}
371
-
}
372
-
}
373
-
374
-
// Update the event record with the new locations if we geocoded something
375
-
if found_coordinates.is_some() {
376
-
let updated_event = match event_record {
377
-
EventLexicon::Current { name, description, created_at, starts_at, ends_at, mode, status, uris, extra, .. } => {
378
-
EventLexicon::Current {
379
-
name,
380
-
description,
381
-
created_at,
382
-
starts_at,
383
-
ends_at,
384
-
mode,
385
-
status,
386
-
locations: updated_locations,
387
-
uris,
388
-
extra,
389
-
}
390
-
}
391
-
};
392
-
(found_coordinates, json!(updated_event))
393
-
} else {
394
-
// No geocoding performed, use original record
395
-
(None, json!(record))
396
-
}
397
-
}
338
+
(existing_coords, json!(record))
398
339
}
399
340
}
400
341
} else {