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")
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}