1use std::collections::HashMap;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5use tokio::sync::RwLock;
6use tracing::instrument;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct UserLocation {
10 pub lat: f64,
11 pub lng: f64,
12 pub accuracy: f64,
13}
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct ContextZone {
17 pub name: String,
18 pub lat: f64,
19 pub lng: f64,
20 pub radius_m: f64,
21}
22
23#[derive(Clone)]
24pub struct GeoService {
25 pub locations: Arc<RwLock<HashMap<i32, UserLocation>>>,
26 pub zones: Arc<RwLock<HashMap<String, ContextZone>>>,
27}
28
29impl GeoService {
30 pub fn new() -> Self {
31 let zones = std::env::var("GEO_ZONES")
32 .ok()
33 .and_then(|json| {
34 serde_json::from_str::<Vec<ContextZone>>(&json)
35 .map_err(|e| tracing::warn!("GEO_ZONES parse error: {e}"))
36 .ok()
37 })
38 .unwrap_or_default();
39
40 let zone_map = zones.into_iter().map(|z| (z.name.clone(), z)).collect();
41
42 Self {
43 locations: Arc::new(RwLock::new(HashMap::new())),
44 zones: Arc::new(RwLock::new(zone_map)),
45 }
46 }
47
48 #[instrument(skip(self))]
49 pub async fn update_location(&self, user_id: i32, loc: UserLocation) {
50 self.locations.write().await.insert(user_id, loc);
51 tracing::info!(user_id, "location updated");
52 }
53
54 #[instrument(skip(self))]
55 pub async fn add_zone(&self, zone: ContextZone) {
56 tracing::info!(zone_name = %zone.name, "zone added");
57 self.zones.write().await.insert(zone.name.clone(), zone);
58 }
59
60 #[allow(dead_code)]
61 pub async fn check_proximity(&self, user_id: i32) -> Vec<String> {
62 let location = self.locations.read().await.get(&user_id).cloned();
63 let Some(loc) = location else {
64 return Vec::new();
65 };
66 let zones = self.zones.read().await.clone();
67 zones
68 .into_values()
69 .filter(|z| {
70 let d = haversine(loc.lat, loc.lng, z.lat, z.lng);
71 d <= z.radius_m
72 })
73 .map(|z| z.name)
74 .collect()
75 }
76}
77
78#[allow(dead_code, clippy::suboptimal_flops)]
79fn haversine(lat1: f64, lng1: f64, lat2: f64, lng2: f64) -> f64 {
80 let r = 6_371_000_f64;
81 let d_lat = (lat2 - lat1).to_radians();
82 let d_lng = (lng2 - lng1).to_radians();
83 let a = (d_lat / 2_f64).sin().powi(2)
84 + lat1.to_radians().cos() * lat2.to_radians().cos() * (d_lng / 2_f64).sin().powi(2);
85 r * 2_f64 * a.sqrt().asin()
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 #[test]
93 fn haversine_known_distance() {
94 let d = haversine(40.4168, -3.7038, 41.3851, 2.1734);
95 assert!((d - 500_000_f64).abs() < 50_000_f64);
96 }
97
98 #[test]
99 fn haversine_zero_distance() {
100 let d = haversine(40.4168, -3.7038, 40.4168, -3.7038);
101 assert!(d < 1_f64);
102 }
103
104 #[tokio::test]
105 async fn check_proximity_empty_when_no_location() {
106 let geo = GeoService::new();
107 let near = geo.check_proximity(1).await;
108 assert!(near.is_empty());
109 }
110}