Skip to main content

oxinbox_backend/
geo.rs

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