The smokesignal.events web application

feature: lfg

+1
Cargo.toml
··· 94 94 urlencoding = "2.1" 95 95 base32 = "0.5.1" 96 96 futures = { version = "0.3.31", default-features = false, features = ["alloc"] } 97 + h3o = "0.6" 97 98 98 99 [profile.release] 99 100 opt-level = 3
+52
lexicon/events.smokesignal.lfg.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "events.smokesignal.lfg", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "description": "A Looking For Group record that broadcasts interest in finding activity partners within a geographic area.", 8 + "key": "tid", 9 + "record": { 10 + "type": "object", 11 + "required": ["location", "tags", "startsAt", "endsAt", "createdAt", "active"], 12 + "properties": { 13 + "location": { 14 + "type": "ref", 15 + "ref": "community.lexicon.location#geo", 16 + "description": "The geographic location for activity partner matching." 17 + }, 18 + "tags": { 19 + "type": "array", 20 + "description": "Interest tags for matching with events and other users.", 21 + "items": { 22 + "type": "string", 23 + "maxLength": 64, 24 + "maxGraphemes": 64 25 + }, 26 + "minLength": 1, 27 + "maxLength": 10 28 + }, 29 + "startsAt": { 30 + "type": "string", 31 + "format": "datetime", 32 + "description": "When the LFG becomes active." 33 + }, 34 + "endsAt": { 35 + "type": "string", 36 + "format": "datetime", 37 + "description": "When the LFG expires and is no longer visible." 38 + }, 39 + "createdAt": { 40 + "type": "string", 41 + "format": "datetime", 42 + "description": "Record creation timestamp." 43 + }, 44 + "active": { 45 + "type": "boolean", 46 + "description": "Whether the LFG is currently active and visible to others." 47 + } 48 + } 49 + } 50 + } 51 + } 52 + }
+304
src/atproto/lexicon/lfg.rs
··· 1 + //! Looking For Group (LFG) lexicon implementation. 2 + //! 3 + //! This module defines the LFG record structure for AT Protocol storage. 4 + //! LFG records allow users to broadcast their interest in finding activity 5 + //! partners within a geographic area for a limited time period. 6 + 7 + use atproto_record::lexicon::community::lexicon::location::LocationOrRef; 8 + use atproto_record::typed::{LexiconType, TypedLexicon}; 9 + use chrono::{DateTime, Utc}; 10 + use h3o::{CellIndex, LatLng, Resolution}; 11 + use serde::{Deserialize, Serialize}; 12 + 13 + /// Minimum H3 precision allowed for LFG locations (inclusive) 14 + pub const MIN_H3_PRECISION: u8 = 4; 15 + 16 + /// Maximum H3 precision allowed for LFG locations (inclusive) 17 + pub const MAX_H3_PRECISION: u8 = 7; 18 + 19 + pub const NSID: &str = "events.smokesignal.lfg"; 20 + 21 + /// Maximum number of tags allowed per LFG record 22 + pub const MAX_TAGS: usize = 10; 23 + 24 + /// Maximum length of each tag in characters 25 + pub const MAX_TAG_LENGTH: usize = 64; 26 + 27 + /// A Looking For Group record that broadcasts interest in finding activity 28 + /// partners within a geographic area. 29 + #[derive(Clone, Serialize, Deserialize, PartialEq)] 30 + #[serde(rename_all = "camelCase")] 31 + pub struct Lfg { 32 + /// The geographic location for activity partner matching 33 + pub location: LocationOrRef, 34 + 35 + /// Interest tags for matching with events and other users (1-10 items) 36 + pub tags: Vec<String>, 37 + 38 + /// When the LFG becomes active 39 + pub starts_at: DateTime<Utc>, 40 + 41 + /// When the LFG expires and is no longer visible 42 + pub ends_at: DateTime<Utc>, 43 + 44 + /// Record creation timestamp 45 + pub created_at: DateTime<Utc>, 46 + 47 + /// Whether the LFG is currently active and visible to others 48 + pub active: bool, 49 + } 50 + 51 + pub type TypedLfg = TypedLexicon<Lfg>; 52 + 53 + impl LexiconType for Lfg { 54 + fn lexicon_type() -> &'static str { 55 + NSID 56 + } 57 + } 58 + 59 + impl Lfg { 60 + /// Validates the LFG record 61 + pub fn validate(&self) -> Result<(), String> { 62 + // Validate tags 63 + if self.tags.is_empty() { 64 + return Err("At least one tag is required".to_string()); 65 + } 66 + if self.tags.len() > MAX_TAGS { 67 + return Err(format!("Maximum {} tags allowed", MAX_TAGS)); 68 + } 69 + for tag in &self.tags { 70 + if tag.trim().is_empty() { 71 + return Err("Tags cannot be empty".to_string()); 72 + } 73 + if tag.len() > MAX_TAG_LENGTH { 74 + return Err(format!( 75 + "Tag '{}' exceeds maximum length of {} characters", 76 + tag, MAX_TAG_LENGTH 77 + )); 78 + } 79 + } 80 + 81 + // Validate time range 82 + if self.ends_at <= self.starts_at { 83 + return Err("End time must be after start time".to_string()); 84 + } 85 + 86 + // Validate location is an H3 cell with appropriate precision 87 + match &self.location { 88 + LocationOrRef::InlineHthree(h3) => { 89 + // Parse the H3 cell index 90 + let cell: CellIndex = h3 91 + .inner 92 + .value 93 + .parse() 94 + .map_err(|_| format!("Invalid H3 cell: {}", h3.inner.value))?; 95 + 96 + // Check precision is within allowed range (4-7) 97 + let resolution: Resolution = cell.resolution(); 98 + let precision = u8::from(resolution); 99 + 100 + if precision < MIN_H3_PRECISION || precision > MAX_H3_PRECISION { 101 + return Err(format!( 102 + "H3 precision must be between {} and {} (got {})", 103 + MIN_H3_PRECISION, MAX_H3_PRECISION, precision 104 + )); 105 + } 106 + } 107 + _ => { 108 + return Err(format!( 109 + "LFG location must be an inline H3 cell with precision {}-{}", 110 + MIN_H3_PRECISION, MAX_H3_PRECISION 111 + )); 112 + } 113 + } 114 + 115 + Ok(()) 116 + } 117 + 118 + /// Extract latitude and longitude from the H3 cell center 119 + pub fn get_coordinates(&self) -> Option<(f64, f64)> { 120 + match &self.location { 121 + LocationOrRef::InlineHthree(h3) => { 122 + let cell: CellIndex = h3.inner.value.parse().ok()?; 123 + let lat_lng: LatLng = cell.into(); 124 + Some((lat_lng.lat(), lat_lng.lng())) 125 + } 126 + _ => None, 127 + } 128 + } 129 + 130 + /// Get the H3 cell index from the location 131 + pub fn get_h3_cell(&self) -> Option<CellIndex> { 132 + match &self.location { 133 + LocationOrRef::InlineHthree(h3) => h3.inner.value.parse().ok(), 134 + _ => None, 135 + } 136 + } 137 + } 138 + 139 + #[cfg(test)] 140 + mod tests { 141 + use super::*; 142 + use atproto_record::lexicon::community::lexicon::location::{Hthree, TypedHthree}; 143 + use chrono::Duration; 144 + 145 + /// Create a test H3 cell at resolution 6 (New York area) 146 + /// Resolution 6 cells are ~36km edge length 147 + fn create_test_h3() -> LocationOrRef { 148 + // H3 cell at resolution 6 covering part of New York 149 + // This is a valid H3 index at resolution 6 150 + LocationOrRef::InlineHthree(TypedHthree::new(Hthree { 151 + value: "862a1072fffffff".to_string(), 152 + name: Some("New York Area".to_string()), 153 + })) 154 + } 155 + 156 + /// Create a test H3 cell at resolution 3 (too low precision) 157 + fn create_test_h3_low_precision() -> LocationOrRef { 158 + LocationOrRef::InlineHthree(TypedHthree::new(Hthree { 159 + value: "832a10fffffffff".to_string(), 160 + name: None, 161 + })) 162 + } 163 + 164 + /// Create a test H3 cell at resolution 8 (too high precision) 165 + fn create_test_h3_high_precision() -> LocationOrRef { 166 + LocationOrRef::InlineHthree(TypedHthree::new(Hthree { 167 + value: "882a1072a9fffff".to_string(), 168 + name: None, 169 + })) 170 + } 171 + 172 + fn create_test_lfg() -> Lfg { 173 + let now = Utc::now(); 174 + Lfg { 175 + location: create_test_h3(), 176 + tags: vec!["hiking".to_string(), "outdoors".to_string()], 177 + starts_at: now, 178 + ends_at: now + Duration::hours(48), 179 + created_at: now, 180 + active: true, 181 + } 182 + } 183 + 184 + #[test] 185 + fn test_lfg_serialization() { 186 + let lfg = create_test_lfg(); 187 + let serialized = serde_json::to_string(&lfg).unwrap(); 188 + 189 + assert!(serialized.contains("\"startsAt\"")); 190 + assert!(serialized.contains("\"endsAt\"")); 191 + assert!(serialized.contains("\"createdAt\"")); 192 + assert!(serialized.contains("\"active\":true")); 193 + assert!(serialized.contains("\"tags\":[\"hiking\",\"outdoors\"]")); 194 + } 195 + 196 + #[test] 197 + fn test_lfg_deserialization() { 198 + let lfg = create_test_lfg(); 199 + let serialized = serde_json::to_string(&lfg).unwrap(); 200 + let deserialized: Lfg = serde_json::from_str(&serialized).unwrap(); 201 + 202 + assert_eq!(deserialized.tags, lfg.tags); 203 + assert_eq!(deserialized.active, lfg.active); 204 + } 205 + 206 + #[test] 207 + fn test_lfg_validation_valid() { 208 + let lfg = create_test_lfg(); 209 + assert!(lfg.validate().is_ok()); 210 + } 211 + 212 + #[test] 213 + fn test_lfg_validation_no_tags() { 214 + let mut lfg = create_test_lfg(); 215 + lfg.tags = vec![]; 216 + assert!(lfg.validate().is_err()); 217 + assert_eq!( 218 + lfg.validate().unwrap_err(), 219 + "At least one tag is required" 220 + ); 221 + } 222 + 223 + #[test] 224 + fn test_lfg_validation_too_many_tags() { 225 + let mut lfg = create_test_lfg(); 226 + lfg.tags = (0..11).map(|i| format!("tag{}", i)).collect(); 227 + assert!(lfg.validate().is_err()); 228 + assert!(lfg.validate().unwrap_err().contains("Maximum 10 tags")); 229 + } 230 + 231 + #[test] 232 + fn test_lfg_validation_empty_tag() { 233 + let mut lfg = create_test_lfg(); 234 + lfg.tags = vec!["hiking".to_string(), " ".to_string()]; 235 + assert!(lfg.validate().is_err()); 236 + assert!(lfg.validate().unwrap_err().contains("empty")); 237 + } 238 + 239 + #[test] 240 + fn test_lfg_validation_invalid_time_range() { 241 + let mut lfg = create_test_lfg(); 242 + lfg.ends_at = lfg.starts_at - Duration::hours(1); 243 + assert!(lfg.validate().is_err()); 244 + assert!(lfg.validate().unwrap_err().contains("End time")); 245 + } 246 + 247 + #[test] 248 + fn test_lfg_get_coordinates() { 249 + let lfg = create_test_lfg(); 250 + let coords = lfg.get_coordinates(); 251 + assert!(coords.is_some()); 252 + let (lat, lon) = coords.unwrap(); 253 + // H3 cell center coordinates (approximate - within the general NY area) 254 + assert!(lat > 40.0 && lat < 42.0, "Latitude should be in NY area"); 255 + assert!(lon > -75.0 && lon < -73.0, "Longitude should be in NY area"); 256 + } 257 + 258 + #[test] 259 + fn test_lfg_get_h3_cell() { 260 + let lfg = create_test_lfg(); 261 + let cell = lfg.get_h3_cell(); 262 + assert!(cell.is_some()); 263 + let cell = cell.unwrap(); 264 + assert_eq!(u8::from(cell.resolution()), 6); 265 + } 266 + 267 + #[test] 268 + fn test_lfg_validation_h3_precision_too_low() { 269 + let mut lfg = create_test_lfg(); 270 + lfg.location = create_test_h3_low_precision(); 271 + let result = lfg.validate(); 272 + assert!(result.is_err()); 273 + assert!(result.unwrap_err().contains("precision must be between")); 274 + } 275 + 276 + #[test] 277 + fn test_lfg_validation_h3_precision_too_high() { 278 + let mut lfg = create_test_lfg(); 279 + lfg.location = create_test_h3_high_precision(); 280 + let result = lfg.validate(); 281 + assert!(result.is_err()); 282 + assert!(result.unwrap_err().contains("precision must be between")); 283 + } 284 + 285 + #[test] 286 + fn test_lfg_validation_non_h3_location() { 287 + use atproto_record::lexicon::community::lexicon::location::{Geo, TypedGeo}; 288 + 289 + let mut lfg = create_test_lfg(); 290 + lfg.location = LocationOrRef::InlineGeo(TypedGeo::new(Geo { 291 + latitude: "40.7128".to_string(), 292 + longitude: "-74.0060".to_string(), 293 + name: Some("New York".to_string()), 294 + })); 295 + let result = lfg.validate(); 296 + assert!(result.is_err()); 297 + assert!(result.unwrap_err().contains("must be an inline H3 cell")); 298 + } 299 + 300 + #[test] 301 + fn test_lexicon_type() { 302 + assert_eq!(Lfg::lexicon_type(), "events.smokesignal.lfg"); 303 + } 304 + }
+1
src/atproto/lexicon/mod.rs
··· 1 1 pub mod acceptance; 2 2 pub mod bluesky_profile; 3 + pub mod lfg; 3 4 pub mod profile;
+1
src/bin/smokesignal.rs
··· 336 336 337 337 match SearchIndexer::new( 338 338 opensearch_endpoint, 339 + pool.clone(), 339 340 identity_resolver.clone(), 340 341 document_storage.clone(), 341 342 )
+92
src/http/auth_utils.rs
··· 1 1 use anyhow::Result; 2 2 use atproto_client::client::get_dpop_json_with_headers; 3 + use deadpool_redis::redis::AsyncCommands; 3 4 use http::HeaderMap; 4 5 use reqwest::Client; 6 + use sha2::{Digest, Sha256}; 5 7 6 8 use crate::atproto::auth::create_dpop_auth_from_aip_session; 7 9 use crate::config::OAuthBackendConfig; ··· 9 11 use crate::http::errors::LoginError; 10 12 use crate::http::errors::web_error::WebError; 11 13 use crate::http::middleware_auth::Auth; 14 + 15 + /// TTL for AIP session ready cache entries (5 minutes). 16 + const AIP_SESSION_READY_CACHE_TTL_SECS: i64 = 300; 12 17 13 18 /// Result of checking if an AIP session is ready for AT Protocol operations. 14 19 pub(crate) enum AipSessionStatus { ··· 82 87 } 83 88 } 84 89 90 + /// Generate a cache key for AIP session ready status. 91 + /// 92 + /// Uses a SHA-256 hash of the access token to avoid storing raw tokens in Redis. 93 + fn aip_session_cache_key(access_token: &str) -> String { 94 + let mut hasher = Sha256::new(); 95 + hasher.update(access_token.as_bytes()); 96 + let hash = hasher.finalize(); 97 + format!("aip_session_ready:{:x}", hash) 98 + } 99 + 100 + /// Check Redis cache for AIP session ready status. 101 + /// 102 + /// Returns `Some(true)` if cached as ready, `Some(false)` if cached as stale, 103 + /// or `None` on cache miss or error. 104 + async fn get_cached_aip_session_status( 105 + web_context: &WebContext, 106 + access_token: &str, 107 + ) -> Option<bool> { 108 + let cache_key = aip_session_cache_key(access_token); 109 + 110 + let mut conn = match web_context.cache_pool.get().await { 111 + Ok(conn) => conn, 112 + Err(e) => { 113 + tracing::debug!(?e, "Failed to get Redis connection for AIP session cache"); 114 + return None; 115 + } 116 + }; 117 + 118 + match conn.get::<_, Option<String>>(&cache_key).await { 119 + Ok(Some(value)) => { 120 + tracing::debug!(cache_key = %cache_key, value = %value, "AIP session cache hit"); 121 + Some(value == "ready") 122 + } 123 + Ok(None) => { 124 + tracing::debug!(cache_key = %cache_key, "AIP session cache miss"); 125 + None 126 + } 127 + Err(e) => { 128 + tracing::debug!(?e, "Redis error reading AIP session cache"); 129 + None 130 + } 131 + } 132 + } 133 + 134 + /// Cache AIP session ready status in Redis with TTL. 135 + async fn cache_aip_session_status(web_context: &WebContext, access_token: &str, is_ready: bool) { 136 + let cache_key = aip_session_cache_key(access_token); 137 + let value = if is_ready { "ready" } else { "stale" }; 138 + 139 + let mut conn = match web_context.cache_pool.get().await { 140 + Ok(conn) => conn, 141 + Err(e) => { 142 + tracing::debug!(?e, "Failed to get Redis connection for AIP session cache write"); 143 + return; 144 + } 145 + }; 146 + 147 + if let Err(e) = conn 148 + .set_ex::<_, _, ()>(&cache_key, value, AIP_SESSION_READY_CACHE_TTL_SECS as u64) 149 + .await 150 + { 151 + tracing::debug!(?e, "Failed to cache AIP session status"); 152 + } else { 153 + tracing::debug!( 154 + cache_key = %cache_key, 155 + value = %value, 156 + ttl_secs = AIP_SESSION_READY_CACHE_TTL_SECS, 157 + "Cached AIP session status" 158 + ); 159 + } 160 + } 161 + 85 162 /// Check if the current AIP session is ready for AT Protocol operations. 86 163 /// 87 164 /// This calls the AIP ready endpoint to validate the access token. 165 + /// Results are cached in Redis for 5 minutes to avoid repeated validation. 88 166 /// For PDS sessions, this is a no-op (returns NotAip). 89 167 pub(crate) async fn require_valid_aip_session( 90 168 web_context: &WebContext, ··· 101 179 return Ok(AipSessionStatus::Stale); 102 180 } 103 181 182 + // Check Redis cache first 183 + if let Some(cached_ready) = 184 + get_cached_aip_session_status(web_context, access_token).await 185 + { 186 + return if cached_ready { 187 + Ok(AipSessionStatus::Ready) 188 + } else { 189 + Ok(AipSessionStatus::Stale) 190 + }; 191 + } 192 + 104 193 // Get AIP hostname from config 105 194 let aip_hostname = match &web_context.config.oauth_backend { 106 195 OAuthBackendConfig::AIP { hostname, .. } => hostname, ··· 119 208 tracing::warn!(?e, "AIP ready check failed"); 120 209 WebError::InternalError 121 210 })?; 211 + 212 + // Cache the result 213 + cache_aip_session_status(web_context, access_token, is_ready).await; 122 214 123 215 if is_ready { 124 216 Ok(AipSessionStatus::Ready)
+79
src/http/errors/lfg_error.rs
··· 1 + use thiserror::Error; 2 + 3 + /// Represents errors that can occur during LFG (Looking For Group) operations. 4 + /// 5 + /// These errors are typically triggered during validation of user-submitted 6 + /// LFG creation forms or during LFG record operations. 7 + #[derive(Debug, Error)] 8 + pub(crate) enum LfgError { 9 + /// Error when the location is not provided. 10 + /// 11 + /// This error occurs when a user attempts to create an LFG record without 12 + /// selecting a location on the map. 13 + #[error("error-smokesignal-lfg-1 Location not set")] 14 + LocationNotSet, 15 + 16 + /// Error when the coordinates are invalid. 17 + /// 18 + /// This error occurs when the provided latitude or longitude 19 + /// values are not valid numbers or are out of range. 20 + #[error("error-smokesignal-lfg-2 Invalid coordinates: {0}")] 21 + InvalidCoordinates(String), 22 + 23 + /// Error when no tags are provided. 24 + /// 25 + /// This error occurs when a user attempts to create an LFG record without 26 + /// specifying at least one interest tag. 27 + #[error("error-smokesignal-lfg-3 Tags required (at least one)")] 28 + TagsRequired, 29 + 30 + /// Error when too many tags are provided. 31 + /// 32 + /// This error occurs when a user attempts to create an LFG record with 33 + /// more than the maximum allowed number of tags (10). 34 + #[error("error-smokesignal-lfg-4 Too many tags (maximum 10)")] 35 + TooManyTags, 36 + 37 + /// Error when an invalid duration is specified. 38 + /// 39 + /// This error occurs when the provided duration value is not one of 40 + /// the allowed options (6, 12, 24, 48, or 72 hours). 41 + #[error("error-smokesignal-lfg-5 Invalid duration")] 42 + InvalidDuration, 43 + 44 + /// Error when the PDS record creation fails. 45 + /// 46 + /// This error occurs when the AT Protocol server returns an error 47 + /// during LFG record creation. 48 + #[error("error-smokesignal-lfg-6 Failed to create PDS record: {message}")] 49 + PdsRecordCreationFailed { message: String }, 50 + 51 + /// Error when no active LFG record is found. 52 + /// 53 + /// This error occurs when attempting to perform operations that 54 + /// require an active LFG record (e.g., deactivation, viewing matches). 55 + #[error("error-smokesignal-lfg-7 No active LFG record found")] 56 + NoActiveRecord, 57 + 58 + /// Error when user already has an active LFG record. 59 + /// 60 + /// This error occurs when a user attempts to create a new LFG record 61 + /// while they already have an active one. Users must deactivate their 62 + /// existing record before creating a new one. 63 + #[error("error-smokesignal-lfg-8 Active LFG record already exists")] 64 + ActiveRecordExists, 65 + 66 + /// Error when deactivation fails. 67 + /// 68 + /// This error occurs when the attempt to deactivate an LFG record 69 + /// fails due to a server or network error. 70 + #[error("error-smokesignal-lfg-9 Failed to deactivate LFG record: {message}")] 71 + DeactivationFailed { message: String }, 72 + 73 + /// Error when a tag is invalid. 74 + /// 75 + /// This error occurs when a provided tag is empty or exceeds 76 + /// the maximum allowed length. 77 + #[error("error-smokesignal-lfg-10 Invalid tag: {0}")] 78 + InvalidTag(String), 79 + }
+2
src/http/errors/mod.rs
··· 8 8 pub mod delete_event_errors; 9 9 pub mod event_view_errors; 10 10 pub mod import_error; 11 + pub mod lfg_error; 11 12 pub mod login_error; 12 13 pub mod profile_import_error; 13 14 pub mod middleware_errors; ··· 21 22 pub(crate) use delete_event_errors::DeleteEventError; 22 23 pub(crate) use event_view_errors::EventViewError; 23 24 pub(crate) use import_error::ImportError; 25 + pub(crate) use lfg_error::LfgError; 24 26 pub(crate) use login_error::LoginError; 25 27 pub(crate) use profile_import_error::ProfileImportError; 26 28 pub(crate) use middleware_errors::WebSessionError;
+8
src/http/errors/web_error.rs
··· 18 18 use super::create_event_errors::CreateEventError; 19 19 use super::event_view_errors::EventViewError; 20 20 use super::import_error::ImportError; 21 + use super::lfg_error::LfgError; 21 22 use super::login_error::LoginError; 22 23 use super::middleware_errors::MiddlewareAuthError; 23 24 use super::url_error::UrlError; ··· 158 159 /// such as avatar/banner uploads or AT Protocol record operations. 159 160 #[error(transparent)] 160 161 BlobError(#[from] BlobError), 162 + 163 + /// Looking For Group (LFG) errors. 164 + /// 165 + /// This error occurs when there are issues with LFG operations, 166 + /// such as creating, viewing, or deactivating LFG records. 167 + #[error(transparent)] 168 + LfgError(#[from] LfgError), 161 169 162 170 /// The AIP session has expired and the user must re-authenticate. 163 171 ///
+233
src/http/h3_utils.rs
··· 1 + //! H3 geospatial indexing utilities. 2 + //! 3 + //! This module provides helper functions for working with H3 hexagonal 4 + //! hierarchical spatial indexes. 5 + 6 + use h3o::{CellIndex, LatLng, Resolution}; 7 + 8 + /// Default H3 resolution for LFG location selection (precision 6 = ~36km edge) 9 + pub const DEFAULT_RESOLUTION: Resolution = Resolution::Six; 10 + 11 + /// Convert lat/lon to H3 cell index at the default resolution (6). 12 + /// 13 + /// # Arguments 14 + /// * `lat` - Latitude in degrees (-90 to 90) 15 + /// * `lon` - Longitude in degrees (-180 to 180) 16 + /// 17 + /// # Returns 18 + /// The H3 cell index as a string, or an error message. 19 + pub fn lat_lon_to_h3(lat: f64, lon: f64) -> Result<String, String> { 20 + lat_lon_to_h3_with_resolution(lat, lon, DEFAULT_RESOLUTION) 21 + } 22 + 23 + /// Convert lat/lon to H3 cell index at a specific resolution. 24 + /// 25 + /// # Arguments 26 + /// * `lat` - Latitude in degrees (-90 to 90) 27 + /// * `lon` - Longitude in degrees (-180 to 180) 28 + /// * `resolution` - H3 resolution (0-15) 29 + /// 30 + /// # Returns 31 + /// The H3 cell index as a string, or an error message. 32 + pub fn lat_lon_to_h3_with_resolution( 33 + lat: f64, 34 + lon: f64, 35 + resolution: Resolution, 36 + ) -> Result<String, String> { 37 + let coord = 38 + LatLng::new(lat, lon).map_err(|e| format!("Invalid coordinates: {}", e))?; 39 + let cell = coord.to_cell(resolution); 40 + Ok(cell.to_string()) 41 + } 42 + 43 + /// Validate an H3 index string and parse it into a CellIndex. 44 + /// 45 + /// # Arguments 46 + /// * `h3_str` - The H3 index as a hexadecimal string 47 + /// 48 + /// # Returns 49 + /// The parsed CellIndex, or an error message. 50 + pub fn validate_h3_index(h3_str: &str) -> Result<CellIndex, String> { 51 + h3_str 52 + .parse::<CellIndex>() 53 + .map_err(|e| format!("Invalid H3 index: {}", e)) 54 + } 55 + 56 + /// Get the center coordinates of an H3 cell. 57 + /// 58 + /// # Arguments 59 + /// * `h3_str` - The H3 index as a hexadecimal string 60 + /// 61 + /// # Returns 62 + /// A tuple of (latitude, longitude) for the cell center, or an error message. 63 + pub fn h3_to_lat_lon(h3_str: &str) -> Result<(f64, f64), String> { 64 + let cell = validate_h3_index(h3_str)?; 65 + let center = LatLng::from(cell); 66 + Ok((center.lat(), center.lng())) 67 + } 68 + 69 + /// Get neighboring H3 cells within k rings. 70 + /// 71 + /// # Arguments 72 + /// * `h3_str` - The H3 index as a hexadecimal string 73 + /// * `k` - The number of rings to include (0 = just the cell, 1 = cell + immediate neighbors) 74 + /// 75 + /// # Returns 76 + /// A vector of H3 cell indexes as strings, or an error message. 77 + pub fn h3_neighbors(h3_str: &str, k: u32) -> Result<Vec<String>, String> { 78 + let cell = validate_h3_index(h3_str)?; 79 + let neighbors: Vec<String> = cell 80 + .grid_disk::<Vec<_>>(k) 81 + .into_iter() 82 + .map(|c| c.to_string()) 83 + .collect(); 84 + Ok(neighbors) 85 + } 86 + 87 + /// Get the boundary vertices of an H3 cell for map display. 88 + /// 89 + /// # Arguments 90 + /// * `h3_str` - The H3 index as a hexadecimal string 91 + /// 92 + /// # Returns 93 + /// A vector of (latitude, longitude) tuples forming the cell boundary, or an error message. 94 + pub fn h3_boundary(h3_str: &str) -> Result<Vec<(f64, f64)>, String> { 95 + let cell = validate_h3_index(h3_str)?; 96 + let boundary = cell.boundary(); 97 + Ok(boundary.iter().map(|v| (v.lat(), v.lng())).collect()) 98 + } 99 + 100 + /// Get the resolution of an H3 cell. 101 + /// 102 + /// # Arguments 103 + /// * `h3_str` - The H3 index as a hexadecimal string 104 + /// 105 + /// # Returns 106 + /// The resolution (0-15), or an error message. 107 + pub fn h3_resolution(h3_str: &str) -> Result<u8, String> { 108 + let cell = validate_h3_index(h3_str)?; 109 + Ok(cell.resolution() as u8) 110 + } 111 + 112 + /// Calculate the approximate area of an H3 cell in square kilometers. 113 + /// 114 + /// # Arguments 115 + /// * `h3_str` - The H3 index as a hexadecimal string 116 + /// 117 + /// # Returns 118 + /// The area in square kilometers, or an error message. 119 + pub fn h3_area_km2(h3_str: &str) -> Result<f64, String> { 120 + let cell = validate_h3_index(h3_str)?; 121 + Ok(cell.area_km2()) 122 + } 123 + 124 + #[cfg(test)] 125 + mod tests { 126 + use super::*; 127 + 128 + #[test] 129 + fn test_lat_lon_to_h3() { 130 + // New York City coordinates 131 + let result = lat_lon_to_h3(40.7128, -74.0060); 132 + assert!(result.is_ok()); 133 + let h3_index = result.unwrap(); 134 + assert!(!h3_index.is_empty()); 135 + // H3 indexes are 15-character hex strings 136 + assert_eq!(h3_index.len(), 15); 137 + } 138 + 139 + #[test] 140 + fn test_lat_lon_to_h3_invalid() { 141 + // Invalid latitude 142 + let result = lat_lon_to_h3(100.0, 0.0); 143 + assert!(result.is_err()); 144 + 145 + // Invalid longitude 146 + let result = lat_lon_to_h3(0.0, 200.0); 147 + assert!(result.is_err()); 148 + } 149 + 150 + #[test] 151 + fn test_h3_to_lat_lon() { 152 + // Convert NYC to H3 and back 153 + let h3_index = lat_lon_to_h3(40.7128, -74.0060).unwrap(); 154 + let (lat, lon) = h3_to_lat_lon(&h3_index).unwrap(); 155 + 156 + // Should be close to original (within cell) 157 + assert!((lat - 40.7128).abs() < 1.0); 158 + assert!((lon - (-74.0060)).abs() < 1.0); 159 + } 160 + 161 + #[test] 162 + fn test_validate_h3_index() { 163 + let h3_index = lat_lon_to_h3(40.7128, -74.0060).unwrap(); 164 + let result = validate_h3_index(&h3_index); 165 + assert!(result.is_ok()); 166 + } 167 + 168 + #[test] 169 + fn test_validate_h3_index_invalid() { 170 + let result = validate_h3_index("invalid"); 171 + assert!(result.is_err()); 172 + } 173 + 174 + #[test] 175 + fn test_h3_neighbors() { 176 + let h3_index = lat_lon_to_h3(40.7128, -74.0060).unwrap(); 177 + 178 + // k=0 should return just the cell itself 179 + let neighbors_0 = h3_neighbors(&h3_index, 0).unwrap(); 180 + assert_eq!(neighbors_0.len(), 1); 181 + assert_eq!(neighbors_0[0], h3_index); 182 + 183 + // k=1 should return the cell plus 6 neighbors (7 total) 184 + let neighbors_1 = h3_neighbors(&h3_index, 1).unwrap(); 185 + assert_eq!(neighbors_1.len(), 7); 186 + assert!(neighbors_1.contains(&h3_index)); 187 + } 188 + 189 + #[test] 190 + fn test_h3_boundary() { 191 + let h3_index = lat_lon_to_h3(40.7128, -74.0060).unwrap(); 192 + let boundary = h3_boundary(&h3_index).unwrap(); 193 + 194 + // H3 cells are hexagons, so they have 6 vertices 195 + assert_eq!(boundary.len(), 6); 196 + 197 + // All vertices should be valid coordinates 198 + for (lat, lon) in &boundary { 199 + assert!((-90.0..=90.0).contains(lat)); 200 + assert!((-180.0..=180.0).contains(lon)); 201 + } 202 + } 203 + 204 + #[test] 205 + fn test_h3_resolution() { 206 + let h3_index = lat_lon_to_h3(40.7128, -74.0060).unwrap(); 207 + let resolution = h3_resolution(&h3_index).unwrap(); 208 + assert_eq!(resolution, 6); // Default resolution 209 + } 210 + 211 + #[test] 212 + fn test_h3_area_km2() { 213 + let h3_index = lat_lon_to_h3(40.7128, -74.0060).unwrap(); 214 + let area = h3_area_km2(&h3_index).unwrap(); 215 + 216 + // Resolution 6 cells are approximately 36 km^2 217 + assert!(area > 30.0 && area < 50.0); 218 + } 219 + 220 + #[test] 221 + fn test_different_resolutions() { 222 + // Test resolution 5 (larger cells) 223 + let h3_res5 = lat_lon_to_h3_with_resolution(40.7128, -74.0060, Resolution::Five).unwrap(); 224 + let area_5 = h3_area_km2(&h3_res5).unwrap(); 225 + 226 + // Test resolution 7 (smaller cells) 227 + let h3_res7 = lat_lon_to_h3_with_resolution(40.7128, -74.0060, Resolution::Seven).unwrap(); 228 + let area_7 = h3_area_km2(&h3_res7).unwrap(); 229 + 230 + // Resolution 5 cells should be larger than resolution 7 231 + assert!(area_5 > area_7); 232 + } 233 + }
+38
src/http/handle_edit_event.rs
··· 6 6 use crate::atproto::auth::{ 7 7 create_dpop_auth_from_aip_session, create_dpop_auth_from_oauth_session, 8 8 }; 9 + use crate::search_index::SearchIndexManager; 9 10 use crate::http::auth_utils::{require_valid_aip_session, AipSessionStatus}; 10 11 use crate::atproto::utils::{location_from_address, location_from_geo}; 11 12 use crate::http::context::UserRequestContext; ··· 433 434 StatusCode::INTERNAL_SERVER_ERROR, 434 435 Json(json!({"error": err.to_string()})) 435 436 ).into_response()); 437 + } 438 + 439 + // Re-index the event in OpenSearch to update locations_geo and other fields 440 + if let Some(endpoint) = &ctx.web_context.config.opensearch_endpoint { 441 + if let Ok(manager) = SearchIndexManager::new(endpoint) { 442 + // Fetch the updated event from the database 443 + match event_get(&ctx.web_context.pool, &put_record_response.uri).await { 444 + Ok(updated_event) => { 445 + if let Err(err) = manager 446 + .index_event( 447 + &ctx.web_context.pool, 448 + ctx.web_context.identity_resolver.clone(), 449 + &updated_event, 450 + ) 451 + .await 452 + { 453 + tracing::warn!( 454 + ?err, 455 + aturi = %put_record_response.uri, 456 + "Failed to re-index event in OpenSearch after edit" 457 + ); 458 + } else { 459 + tracing::info!( 460 + aturi = %put_record_response.uri, 461 + "Successfully re-indexed event in OpenSearch after edit" 462 + ); 463 + } 464 + } 465 + Err(err) => { 466 + tracing::warn!( 467 + ?err, 468 + aturi = %put_record_response.uri, 469 + "Failed to fetch updated event for OpenSearch indexing" 470 + ); 471 + } 472 + } 473 + } 436 474 } 437 475 438 476 // Download and store header image from PDS if one was uploaded
+2 -2
src/http/handle_geo_aggregation.rs
··· 9 9 use crate::search_index::{GeoCenter, GeoHexBucket, SearchIndexManager}; 10 10 11 11 /// H3 precision level (5 = ~8.5km edge length) 12 - const PRECISION: u8 = 6; 12 + const PRECISION: u8 = 7; 13 13 /// Search radius in miles 14 - const DISTANCE_MILES: f64 = 300.0; 14 + const DISTANCE_MILES: f64 = 60.0; 15 15 16 16 #[derive(Debug, Deserialize)] 17 17 pub(crate) struct GeoAggregationParams {
+6
src/http/handle_index.rs
··· 414 414 .unwrap_or(crate::stats::NetworkStats { 415 415 event_count: 0, 416 416 rsvp_count: 0, 417 + lfg_identities_count: 0, 418 + lfg_locations_count: 0, 417 419 }); 418 420 419 421 let event_count_formatted = crate::stats::NetworkStats::format_number(stats.event_count); 420 422 let rsvp_count_formatted = crate::stats::NetworkStats::format_number(stats.rsvp_count); 423 + let lfg_identities_count_formatted = crate::stats::NetworkStats::format_number(stats.lfg_identities_count); 424 + let lfg_locations_count_formatted = crate::stats::NetworkStats::format_number(stats.lfg_locations_count); 421 425 422 426 Ok(( 423 427 http::StatusCode::OK, ··· 433 437 pagination => pagination_view, 434 438 event_count => event_count_formatted, 435 439 rsvp_count => rsvp_count_formatted, 440 + lfg_identities_count => lfg_identities_count_formatted, 441 + lfg_locations_count => lfg_locations_count_formatted, 436 442 }, 437 443 ), 438 444 )
+855
src/http/handle_lfg.rs
··· 1 + //! HTTP handlers for the Looking For Group (LFG) feature. 2 + //! 3 + //! This module provides handlers for creating, viewing, and managing LFG records 4 + //! that allow users to find activity partners in their geographic area. 5 + 6 + use atproto_client::com::atproto::repo::{ 7 + CreateRecordRequest, CreateRecordResponse, PutRecordRequest, PutRecordResponse, create_record, 8 + put_record, 9 + }; 10 + use atproto_record::lexicon::community::lexicon::location::{Hthree, LocationOrRef, TypedHthree}; 11 + use axum::Json; 12 + use axum::extract::State; 13 + use axum::response::IntoResponse; 14 + use axum_extra::extract::Cached; 15 + use axum_extra::extract::Query; 16 + use axum_htmx::{HxBoosted, HxRequest}; 17 + use axum_template::RenderHtml; 18 + use chrono::{Duration, Utc}; 19 + use h3o::{LatLng, Resolution}; 20 + use minijinja::context as template_context; 21 + use serde::{Deserialize, Serialize}; 22 + 23 + use std::collections::HashMap; 24 + 25 + use crate::atproto::auth::{ 26 + create_dpop_auth_from_aip_session, create_dpop_auth_from_oauth_session, 27 + }; 28 + use crate::atproto::lexicon::lfg::{Lfg, NSID}; 29 + use crate::config::OAuthBackendConfig; 30 + use crate::http::auth_utils::{AipSessionStatus, require_valid_aip_session}; 31 + use crate::http::context::WebContext; 32 + use crate::http::errors::{CommonError, LfgError, WebError}; 33 + use crate::http::event_view::EventView; 34 + use crate::http::lfg_form::{ALLOWED_DURATIONS, DEFAULT_DURATION_HOURS, MAX_TAGS, MAX_TAG_LENGTH}; 35 + use crate::http::middleware_auth::Auth; 36 + use crate::http::middleware_i18n::Language; 37 + use crate::search_index::{GeoCenter, IndexedEvent, IndexedLfgProfile, SearchIndexManager}; 38 + use crate::select_template; 39 + use crate::storage::atproto_record::atproto_record_upsert; 40 + use crate::storage::event::event_get; 41 + use crate::storage::identity_profile::{handle_for_did, handles_by_did}; 42 + use crate::storage::lfg::{lfg_get_active_by_did, lfg_get_all_by_did}; 43 + use crate::storage::profile::profile_get_by_did; 44 + use crate::storage::StoragePool; 45 + 46 + // ============================================================================ 47 + // Request/Response Types 48 + // ============================================================================ 49 + 50 + /// Request body for creating an LFG record. 51 + #[derive(Debug, Deserialize)] 52 + pub struct CreateLfgRequest { 53 + /// Latitude of the location 54 + pub latitude: f64, 55 + /// Longitude of the location 56 + pub longitude: f64, 57 + /// Interest tags (1-10 items) 58 + pub tags: Vec<String>, 59 + /// Duration in hours (6, 12, 24, 48, or 72) 60 + pub duration_hours: u32, 61 + } 62 + 63 + /// Response for successful LFG creation. 64 + #[derive(Debug, Serialize)] 65 + pub struct CreateLfgResponse { 66 + /// AT-URI of the created record 67 + pub aturi: String, 68 + /// CID of the record 69 + pub cid: String, 70 + } 71 + 72 + /// Error response for LFG operations. 73 + #[derive(Debug, Serialize)] 74 + pub struct LfgErrorResponse { 75 + /// Error code 76 + pub error: String, 77 + /// Human-readable error message 78 + pub message: String, 79 + } 80 + 81 + /// Query parameters for tag autocomplete 82 + #[derive(Debug, Deserialize)] 83 + pub struct TagAutocompleteQuery { 84 + /// Search query (prefix match) 85 + #[serde(default)] 86 + pub q: String, 87 + /// Maximum number of results 88 + #[serde(default = "default_limit")] 89 + pub limit: u32, 90 + } 91 + 92 + fn default_limit() -> u32 { 93 + 10 94 + } 95 + 96 + /// Tag suggestion in autocomplete response 97 + #[derive(Debug, Serialize)] 98 + pub struct TagSuggestion { 99 + /// Tag name 100 + pub name: String, 101 + /// Usage count 102 + pub count: i64, 103 + /// Source: "history" (user's past tags) or "popular" (global) 104 + pub source: String, 105 + } 106 + 107 + /// Response for tag autocomplete endpoint 108 + #[derive(Debug, Serialize)] 109 + pub struct TagAutocompleteResponse { 110 + pub tags: Vec<TagSuggestion>, 111 + } 112 + 113 + /// Query parameters for geo aggregation 114 + #[derive(Debug, Deserialize)] 115 + pub struct GeoAggregationQuery { 116 + /// Center latitude (optional, uses LFG location if available) 117 + pub lat: Option<f64>, 118 + /// Center longitude (optional, uses LFG location if available) 119 + pub lon: Option<f64>, 120 + /// H3 precision (default 6) 121 + #[serde(default = "default_precision")] 122 + pub precision: u8, 123 + } 124 + 125 + fn default_precision() -> u8 { 126 + 6 127 + } 128 + 129 + /// Bucket in geo aggregation response 130 + #[derive(Debug, Serialize)] 131 + pub struct GeoHexBucket { 132 + /// H3 cell index 133 + pub key: String, 134 + /// Total count (events + people) 135 + pub doc_count: u64, 136 + /// Event count 137 + pub event_count: u64, 138 + /// People count 139 + pub people_count: u64, 140 + } 141 + 142 + /// Response for geo aggregation endpoint 143 + #[derive(Debug, Serialize)] 144 + pub struct GeoAggregationResponse { 145 + pub buckets: Vec<GeoHexBucket>, 146 + pub lat: f64, 147 + pub lon: f64, 148 + } 149 + 150 + // ============================================================================ 151 + // Helper Functions 152 + // ============================================================================ 153 + 154 + /// Helper to get popular tags from OpenSearch with graceful fallback. 155 + /// 156 + /// Returns an empty vector if OpenSearch is not configured or unavailable. 157 + async fn get_popular_tags(opensearch_endpoint: Option<&str>, limit: u32) -> Vec<(String, i64)> { 158 + let Some(endpoint) = opensearch_endpoint else { 159 + return vec![]; 160 + }; 161 + 162 + let Ok(manager) = SearchIndexManager::new(endpoint) else { 163 + return vec![]; 164 + }; 165 + 166 + manager 167 + .get_popular_lfg_tags(limit) 168 + .await 169 + .unwrap_or_default() 170 + } 171 + 172 + 173 + /// Serializable LFG profile for template display 174 + #[derive(Debug, Serialize)] 175 + struct TemplateProfile { 176 + did: String, 177 + handle: Option<String>, 178 + display_name: Option<String>, 179 + tags: Vec<String>, 180 + } 181 + 182 + impl TemplateProfile { 183 + /// Create from IndexedLfgProfile with optional profile enrichment 184 + fn from_indexed(p: IndexedLfgProfile) -> Self { 185 + Self { 186 + did: p.did, 187 + handle: None, 188 + display_name: None, 189 + tags: p.tags, 190 + } 191 + } 192 + } 193 + 194 + /// Serializable geo bucket for template display 195 + #[derive(Debug, Serialize)] 196 + struct TemplateBucket { 197 + key: String, 198 + count: u64, 199 + } 200 + 201 + /// Enrich LFG profiles with display_name and handle from the database. 202 + async fn enrich_profiles(pool: &StoragePool, profiles: Vec<IndexedLfgProfile>) -> Vec<TemplateProfile> { 203 + let mut enriched = Vec::with_capacity(profiles.len()); 204 + 205 + for p in profiles { 206 + let did = p.did.clone(); 207 + let mut template_profile = TemplateProfile::from_indexed(p); 208 + 209 + // Try to get AT Protocol profile for display_name 210 + if let Ok(Some(profile)) = profile_get_by_did(pool, &did).await { 211 + if !profile.display_name.is_empty() { 212 + template_profile.display_name = Some(profile.display_name); 213 + } 214 + } 215 + 216 + // Try to get identity profile for handle 217 + if let Ok(identity) = handle_for_did(pool, &did).await { 218 + template_profile.handle = Some(identity.handle); 219 + } 220 + 221 + enriched.push(template_profile); 222 + } 223 + 224 + enriched 225 + } 226 + 227 + /// Query OpenSearch for nearby events, profiles, and geo aggregations. 228 + /// 229 + /// Returns a tuple of (indexed_events, profiles, event_buckets, profile_buckets). 230 + /// Indexed events need to be further enriched by fetching from the database. 231 + async fn query_nearby_activity( 232 + pool: &StoragePool, 233 + opensearch_endpoint: Option<&str>, 234 + lat: f64, 235 + lon: f64, 236 + distance_miles: f64, 237 + tags: &[String], 238 + ) -> ( 239 + Vec<IndexedEvent>, 240 + Vec<TemplateProfile>, 241 + Vec<TemplateBucket>, 242 + Vec<TemplateBucket>, 243 + ) { 244 + let Some(endpoint) = opensearch_endpoint else { 245 + return (vec![], vec![], vec![], vec![]); 246 + }; 247 + 248 + let Ok(manager) = SearchIndexManager::new(endpoint) else { 249 + return (vec![], vec![], vec![], vec![]); 250 + }; 251 + 252 + let center = GeoCenter { 253 + lat, 254 + lon, 255 + distance_miles, 256 + }; 257 + 258 + // Run queries in parallel 259 + let (events_result, profiles_result, event_agg_result, profile_agg_result) = tokio::join!( 260 + manager.search_nearby_upcoming_events(lat, lon, distance_miles, 20), 261 + manager.search_nearby_lfg_profiles(lat, lon, distance_miles, tags, None, 20), 262 + manager.get_event_geo_aggregation(7, Some(center.clone()), true), 263 + manager.get_lfg_profile_geo_aggregation(7, Some(center)), 264 + ); 265 + 266 + let indexed_events = events_result.unwrap_or_default(); 267 + 268 + // Enrich profiles with display_name and handle 269 + let indexed_profiles = profiles_result.unwrap_or_default(); 270 + let profiles = enrich_profiles(pool, indexed_profiles).await; 271 + 272 + let event_buckets: Vec<TemplateBucket> = event_agg_result 273 + .unwrap_or_default() 274 + .into_iter() 275 + .map(|b| TemplateBucket { 276 + key: b.key, 277 + count: b.doc_count, 278 + }) 279 + .collect(); 280 + 281 + let profile_buckets: Vec<TemplateBucket> = profile_agg_result 282 + .unwrap_or_default() 283 + .into_iter() 284 + .map(|b| TemplateBucket { 285 + key: b.key, 286 + count: b.doc_count, 287 + }) 288 + .collect(); 289 + 290 + (indexed_events, profiles, event_buckets, profile_buckets) 291 + } 292 + 293 + /// Validate a CreateLfgRequest and return an error if invalid. 294 + fn validate_create_lfg_request(request: &CreateLfgRequest) -> Result<(), LfgError> { 295 + // Validate coordinates 296 + if !(-90.0..=90.0).contains(&request.latitude) { 297 + return Err(LfgError::InvalidCoordinates( 298 + "latitude out of range".to_string(), 299 + )); 300 + } 301 + if !(-180.0..=180.0).contains(&request.longitude) { 302 + return Err(LfgError::InvalidCoordinates( 303 + "longitude out of range".to_string(), 304 + )); 305 + } 306 + 307 + // Validate tags 308 + if request.tags.is_empty() { 309 + return Err(LfgError::TagsRequired); 310 + } 311 + if request.tags.len() > MAX_TAGS { 312 + return Err(LfgError::TooManyTags); 313 + } 314 + for tag in &request.tags { 315 + let trimmed = tag.trim(); 316 + if trimmed.is_empty() { 317 + return Err(LfgError::InvalidTag("empty tag".to_string())); 318 + } 319 + if trimmed.len() > MAX_TAG_LENGTH { 320 + return Err(LfgError::InvalidTag(format!( 321 + "tag exceeds {} characters", 322 + MAX_TAG_LENGTH 323 + ))); 324 + } 325 + } 326 + 327 + // Validate duration 328 + if !ALLOWED_DURATIONS.contains(&request.duration_hours) { 329 + return Err(LfgError::InvalidDuration); 330 + } 331 + 332 + Ok(()) 333 + } 334 + 335 + // ============================================================================ 336 + // GET Handler - Display Form or Matches 337 + // ============================================================================ 338 + 339 + /// GET /lfg - Display the LFG form or matches view 340 + /// 341 + /// If the user has no active LFG record, shows the creation form. 342 + /// If the user has an active LFG record, shows the matches view. 343 + pub(crate) async fn handle_lfg_get( 344 + State(web_context): State<WebContext>, 345 + Language(language): Language, 346 + Cached(auth): Cached<Auth>, 347 + HxRequest(hx_request): HxRequest, 348 + HxBoosted(hx_boosted): HxBoosted, 349 + ) -> Result<impl IntoResponse, WebError> { 350 + let current_handle = auth.require("/lfg")?; 351 + 352 + let is_development = cfg!(debug_assertions); 353 + 354 + let default_context = template_context! { 355 + current_handle => current_handle.clone(), 356 + language => language.to_string(), 357 + canonical_url => format!("https://{}/lfg", web_context.config.external_base), 358 + is_development, 359 + allowed_durations => ALLOWED_DURATIONS, 360 + default_duration => DEFAULT_DURATION_HOURS, 361 + }; 362 + 363 + // Check for existing active LFG record 364 + let active_lfg = lfg_get_active_by_did(&web_context.pool, &current_handle.did).await?; 365 + 366 + match active_lfg { 367 + None => { 368 + // No active LFG - show creation form 369 + let popular_tags = 370 + get_popular_tags(web_context.config.opensearch_endpoint.as_deref(), 20).await; 371 + 372 + Ok(RenderHtml( 373 + select_template!("lfg_form", hx_boosted, hx_request, language), 374 + web_context.engine.clone(), 375 + template_context! { ..default_context, ..template_context! { 376 + popular_tags, 377 + }}, 378 + ) 379 + .into_response()) 380 + } 381 + Some(lfg_record) => { 382 + // User has an active LFG - show matches view 383 + let lfg: Lfg = serde_json::from_value(lfg_record.record.0.clone()) 384 + .map_err(|_| LfgError::NoActiveRecord)?; 385 + 386 + // Extract coordinates and H3 cell from the LFG record 387 + let (lat, lon) = lfg.get_coordinates().ok_or(LfgError::LocationNotSet)?; 388 + let h3_cell = lfg.get_h3_cell().map(|c| c.to_string()); 389 + 390 + // Query OpenSearch for nearby events and profiles 391 + let search_radius_miles = 100.0; // Search within 100 miles 392 + let (indexed_events, matching_profiles, event_buckets, profile_buckets) = 393 + query_nearby_activity( 394 + &web_context.pool, 395 + web_context.config.opensearch_endpoint.as_deref(), 396 + lat, 397 + lon, 398 + search_radius_miles, 399 + &lfg.tags, 400 + ) 401 + .await; 402 + 403 + // Fetch full events from database 404 + let mut db_events = vec![]; 405 + for indexed_event in &indexed_events { 406 + match event_get(&web_context.pool, &indexed_event.aturi).await { 407 + Ok(event) => db_events.push(event), 408 + Err(err) => { 409 + tracing::warn!("Failed to fetch event {}: {}", indexed_event.aturi, err); 410 + } 411 + } 412 + } 413 + 414 + // Get organizer handles 415 + let event_dids: Vec<String> = db_events.iter().map(|e| e.did.clone()).collect(); 416 + let organizer_handles = handles_by_did(&web_context.pool, event_dids) 417 + .await 418 + .unwrap_or_else(|_| HashMap::new()); 419 + 420 + // Build EventViews 421 + let facet_limits = crate::facets::FacetLimits { 422 + mentions_max: web_context.config.facets_mentions_max, 423 + tags_max: web_context.config.facets_tags_max, 424 + links_max: web_context.config.facets_links_max, 425 + max: web_context.config.facets_max, 426 + }; 427 + 428 + let mut events: Vec<EventView> = db_events 429 + .iter() 430 + .filter_map(|event| { 431 + let organizer = organizer_handles.get(&event.did); 432 + EventView::try_from(( 433 + auth.profile(), 434 + organizer, 435 + event, 436 + &facet_limits, 437 + )) 438 + .ok() 439 + }) 440 + .collect(); 441 + 442 + // Hydrate RSVP counts 443 + if let Err(err) = crate::http::event_view::hydrate_event_rsvp_counts( 444 + &web_context.pool, 445 + &mut events, 446 + ) 447 + .await 448 + { 449 + tracing::warn!("Failed to hydrate event RSVP counts: {}", err); 450 + } 451 + 452 + // Extract user's tags for highlighting matches in the template 453 + let user_tags: Vec<String> = lfg.tags.iter().map(|t| t.to_lowercase()).collect(); 454 + 455 + Ok(RenderHtml( 456 + select_template!("lfg_matches", hx_boosted, hx_request, language), 457 + web_context.engine.clone(), 458 + template_context! { ..default_context, ..template_context! { 459 + lfg_record => serde_json::to_value(&lfg).ok(), 460 + lfg_aturi => lfg_record.aturi, 461 + latitude => lat, 462 + longitude => lon, 463 + h3_cell, 464 + events, 465 + matching_profiles, 466 + event_buckets, 467 + profile_buckets, 468 + user_tags, 469 + }}, 470 + ) 471 + .into_response()) 472 + } 473 + } 474 + } 475 + 476 + // ============================================================================ 477 + // POST Handler - Create LFG Record (JSON) 478 + // ============================================================================ 479 + 480 + /// POST /lfg - Create a new LFG record 481 + /// 482 + /// Accepts a JSON body with location, tags, and duration. 483 + /// Returns JSON with the created record's AT-URI and CID. 484 + pub(crate) async fn handle_lfg_post( 485 + State(web_context): State<WebContext>, 486 + Cached(auth): Cached<Auth>, 487 + Json(request): Json<CreateLfgRequest>, 488 + ) -> Result<Json<CreateLfgResponse>, WebError> { 489 + let current_handle = auth.require("/lfg")?; 490 + 491 + // Check AIP session validity before attempting AT Protocol operation 492 + if let AipSessionStatus::Stale = require_valid_aip_session(&web_context, &auth).await? { 493 + return Err(WebError::SessionStale); 494 + } 495 + 496 + // Validate the request 497 + validate_create_lfg_request(&request)?; 498 + 499 + // Check if user already has an active LFG record 500 + let active_lfg = lfg_get_active_by_did(&web_context.pool, &current_handle.did).await?; 501 + if active_lfg.is_some() { 502 + return Err(LfgError::ActiveRecordExists.into()); 503 + } 504 + 505 + // Normalize tags (trim, remove duplicates case-insensitively, preserve original case) 506 + let tags: Vec<String> = { 507 + let mut seen = std::collections::HashSet::new(); 508 + request 509 + .tags 510 + .iter() 511 + .map(|t| t.trim().to_string()) 512 + .filter(|t| !t.is_empty() && seen.insert(t.to_lowercase())) 513 + .collect() 514 + }; 515 + 516 + // Convert lat/lng to H3 cell at resolution 7 517 + let lat_lng = 518 + LatLng::new(request.latitude, request.longitude).map_err(|_| LfgError::LocationNotSet)?; 519 + let cell = lat_lng.to_cell(Resolution::Seven); 520 + 521 + let location = LocationOrRef::InlineHthree(TypedHthree::new(Hthree { 522 + value: cell.to_string(), 523 + name: None, 524 + })); 525 + 526 + // Create the LFG record 527 + let now = Utc::now(); 528 + let ends_at = now + Duration::hours(request.duration_hours as i64); 529 + 530 + let lfg_record = Lfg { 531 + location, 532 + tags, 533 + starts_at: now, 534 + ends_at, 535 + created_at: now, 536 + active: true, 537 + }; 538 + 539 + // Create DPoP auth based on OAuth backend type 540 + let dpop_auth = match (&auth, &web_context.config.oauth_backend) { 541 + (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => { 542 + create_dpop_auth_from_oauth_session(session)? 543 + } 544 + (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => { 545 + create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token) 546 + .await? 547 + } 548 + _ => return Err(CommonError::NotAuthorized.into()), 549 + }; 550 + 551 + let create_request = CreateRecordRequest { 552 + repo: current_handle.did.clone(), 553 + collection: NSID.to_string(), 554 + validate: false, 555 + record_key: None, 556 + record: lfg_record.clone(), 557 + swap_commit: None, 558 + }; 559 + 560 + let create_result = create_record( 561 + &web_context.http_client, 562 + &atproto_client::client::Auth::DPoP(dpop_auth), 563 + &current_handle.pds, 564 + create_request, 565 + ) 566 + .await; 567 + 568 + let (aturi, cid) = match create_result { 569 + Ok(CreateRecordResponse::StrongRef { uri, cid, .. }) => (uri, cid), 570 + Ok(CreateRecordResponse::Error(err)) => { 571 + return Err(LfgError::PdsRecordCreationFailed { 572 + message: err.error_message(), 573 + } 574 + .into()); 575 + } 576 + Err(err) => { 577 + return Err(LfgError::PdsRecordCreationFailed { 578 + message: err.to_string(), 579 + } 580 + .into()); 581 + } 582 + }; 583 + 584 + // Store in local database 585 + let record_json = serde_json::to_value(&lfg_record).map_err(|e| { 586 + LfgError::PdsRecordCreationFailed { 587 + message: e.to_string(), 588 + } 589 + })?; 590 + 591 + atproto_record_upsert( 592 + &web_context.pool, 593 + &aturi, 594 + &current_handle.did, 595 + &cid, 596 + NSID, 597 + &record_json, 598 + ) 599 + .await?; 600 + 601 + // Index to OpenSearch 602 + if let Some(endpoint) = &web_context.config.opensearch_endpoint { 603 + if let Ok(manager) = SearchIndexManager::new(endpoint) { 604 + if let Some((lat, lon)) = lfg_record.get_coordinates() { 605 + if let Err(e) = manager 606 + .index_lfg_profile( 607 + &aturi, 608 + &current_handle.did, 609 + lat, 610 + lon, 611 + &lfg_record.tags, 612 + &lfg_record.starts_at, 613 + &lfg_record.ends_at, 614 + &lfg_record.created_at, 615 + true, // active = true 616 + ) 617 + .await 618 + { 619 + tracing::warn!("Failed to index LFG profile to search index: {}", e); 620 + } 621 + } 622 + } 623 + } 624 + 625 + Ok(Json(CreateLfgResponse { 626 + aturi, 627 + cid: cid.to_string(), 628 + })) 629 + } 630 + 631 + // ============================================================================ 632 + // POST Handler - Deactivate LFG Record 633 + // ============================================================================ 634 + 635 + /// POST /lfg/deactivate - Deactivate the user's active LFG record 636 + pub(crate) async fn handle_lfg_deactivate( 637 + State(web_context): State<WebContext>, 638 + Cached(auth): Cached<Auth>, 639 + ) -> Result<impl IntoResponse, WebError> { 640 + let current_handle = auth.require("/lfg/deactivate")?; 641 + 642 + // Check AIP session validity 643 + if let AipSessionStatus::Stale = require_valid_aip_session(&web_context, &auth).await? { 644 + return Err(WebError::SessionStale); 645 + } 646 + 647 + // Get the active LFG record 648 + let active_lfg = lfg_get_active_by_did(&web_context.pool, &current_handle.did) 649 + .await? 650 + .ok_or(LfgError::NoActiveRecord)?; 651 + 652 + // Parse the existing record and create updated version with active=false 653 + let mut lfg: Lfg = serde_json::from_value(active_lfg.record.0.clone()) 654 + .map_err(|_| LfgError::NoActiveRecord)?; 655 + lfg.active = false; 656 + 657 + // Create DPoP auth 658 + let dpop_auth = match (&auth, &web_context.config.oauth_backend) { 659 + (Auth::Pds { session, .. }, OAuthBackendConfig::ATProtocol { .. }) => { 660 + create_dpop_auth_from_oauth_session(session)? 661 + } 662 + (Auth::Aip { access_token, .. }, OAuthBackendConfig::AIP { hostname, .. }) => { 663 + create_dpop_auth_from_aip_session(&web_context.http_client, hostname, access_token) 664 + .await? 665 + } 666 + _ => return Err(CommonError::NotAuthorized.into()), 667 + }; 668 + 669 + // Extract rkey from AT-URI 670 + let rkey = active_lfg 671 + .aturi 672 + .rsplit('/') 673 + .next() 674 + .ok_or(LfgError::NoActiveRecord)?; 675 + 676 + // Update the record on PDS with active=false 677 + let put_request = PutRecordRequest { 678 + repo: current_handle.did.clone(), 679 + collection: NSID.to_string(), 680 + record_key: rkey.to_string(), 681 + validate: false, 682 + record: lfg.clone(), 683 + swap_record: None, 684 + swap_commit: None, 685 + }; 686 + 687 + let put_result = put_record( 688 + &web_context.http_client, 689 + &atproto_client::client::Auth::DPoP(dpop_auth), 690 + &current_handle.pds, 691 + put_request, 692 + ) 693 + .await; 694 + 695 + let (aturi, cid) = match put_result { 696 + Ok(PutRecordResponse::StrongRef { uri, cid, .. }) => (uri, cid), 697 + Ok(PutRecordResponse::Error(err)) => { 698 + return Err(LfgError::DeactivationFailed { 699 + message: err.error_message(), 700 + } 701 + .into()); 702 + } 703 + Err(err) => { 704 + return Err(LfgError::DeactivationFailed { 705 + message: err.to_string(), 706 + } 707 + .into()); 708 + } 709 + }; 710 + 711 + // Update local database with deactivated record 712 + let record_json = serde_json::to_value(&lfg).map_err(|e| LfgError::DeactivationFailed { 713 + message: e.to_string(), 714 + })?; 715 + 716 + atproto_record_upsert( 717 + &web_context.pool, 718 + &aturi, 719 + &current_handle.did, 720 + &cid, 721 + NSID, 722 + &record_json, 723 + ) 724 + .await?; 725 + 726 + // Update OpenSearch index with active=false 727 + if let Some(endpoint) = &web_context.config.opensearch_endpoint { 728 + if let Ok(manager) = SearchIndexManager::new(endpoint) { 729 + if let Some((lat, lon)) = lfg.get_coordinates() { 730 + if let Err(e) = manager 731 + .index_lfg_profile( 732 + &aturi, 733 + &current_handle.did, 734 + lat, 735 + lon, 736 + &lfg.tags, 737 + &lfg.starts_at, 738 + &lfg.ends_at, 739 + &lfg.created_at, 740 + false, // active = false 741 + ) 742 + .await 743 + { 744 + tracing::warn!("Failed to update LFG profile in search index: {}", e); 745 + } 746 + } 747 + } 748 + } 749 + 750 + // Redirect to LFG page (will show form since no active record) 751 + Ok(axum::response::Redirect::to("/lfg").into_response()) 752 + } 753 + 754 + // ============================================================================ 755 + // API Handlers 756 + // ============================================================================ 757 + 758 + /// GET /api/lfg/tags - Tag autocomplete 759 + pub(crate) async fn handle_lfg_tags_autocomplete( 760 + State(web_context): State<WebContext>, 761 + Cached(auth): Cached<Auth>, 762 + Query(query): Query<TagAutocompleteQuery>, 763 + ) -> Result<Json<TagAutocompleteResponse>, WebError> { 764 + let current_handle = auth.require("/api/lfg/tags")?; 765 + 766 + let mut suggestions: Vec<TagSuggestion> = Vec::new(); 767 + let query_lower = query.q.to_lowercase(); 768 + 769 + // Get user's historical tags 770 + let user_records = lfg_get_all_by_did(&web_context.pool, &current_handle.did, 50) 771 + .await 772 + .unwrap_or_default(); 773 + 774 + let mut user_tag_counts: std::collections::HashMap<String, i64> = 775 + std::collections::HashMap::new(); 776 + 777 + for record in &user_records { 778 + if let Ok(lfg) = serde_json::from_value::<Lfg>(record.record.0.clone()) { 779 + for tag in lfg.tags { 780 + let normalized = tag.to_lowercase(); 781 + *user_tag_counts.entry(normalized).or_insert(0) += 1; 782 + } 783 + } 784 + } 785 + 786 + // Add user's historical tags (matching prefix) 787 + for (tag, count) in &user_tag_counts { 788 + if query_lower.is_empty() || tag.starts_with(&query_lower) { 789 + suggestions.push(TagSuggestion { 790 + name: tag.clone(), 791 + count: *count, 792 + source: "history".to_string(), 793 + }); 794 + } 795 + } 796 + 797 + // Get popular tags globally from OpenSearch 798 + let popular_tags = 799 + get_popular_tags(web_context.config.opensearch_endpoint.as_deref(), 50).await; 800 + 801 + // Add popular tags (matching prefix, excluding already added) 802 + for (tag, count) in popular_tags { 803 + let normalized = tag.to_lowercase(); 804 + if !user_tag_counts.contains_key(&normalized) 805 + && (query_lower.is_empty() || normalized.starts_with(&query_lower)) 806 + { 807 + suggestions.push(TagSuggestion { 808 + name: tag, 809 + count, 810 + source: "popular".to_string(), 811 + }); 812 + } 813 + } 814 + 815 + // Sort: history first, then by count descending 816 + suggestions.sort_by(|a, b| { 817 + let source_order = match (a.source.as_str(), b.source.as_str()) { 818 + ("history", "popular") => std::cmp::Ordering::Less, 819 + ("popular", "history") => std::cmp::Ordering::Greater, 820 + _ => std::cmp::Ordering::Equal, 821 + }; 822 + source_order.then(b.count.cmp(&a.count)) 823 + }); 824 + 825 + // Limit results 826 + suggestions.truncate(query.limit as usize); 827 + 828 + Ok(Json(TagAutocompleteResponse { tags: suggestions })) 829 + } 830 + 831 + /// GET /api/lfg/geo-aggregation - Geo aggregation for heatmap 832 + pub(crate) async fn handle_lfg_geo_aggregation( 833 + State(web_context): State<WebContext>, 834 + Cached(auth): Cached<Auth>, 835 + Query(query): Query<GeoAggregationQuery>, 836 + ) -> Result<Json<GeoAggregationResponse>, WebError> { 837 + let current_handle = auth.require("/api/lfg/geo-aggregation")?; 838 + 839 + // Get the user's active LFG record for location 840 + let active_lfg = lfg_get_active_by_did(&web_context.pool, &current_handle.did).await?; 841 + 842 + let (lat, lon) = if let Some(ref lfg_record) = active_lfg { 843 + serde_json::from_value::<Lfg>(lfg_record.record.0.clone()) 844 + .ok() 845 + .and_then(|lfg| lfg.get_coordinates()) 846 + .unwrap_or((query.lat.unwrap_or(0.0), query.lon.unwrap_or(0.0))) 847 + } else { 848 + (query.lat.unwrap_or(0.0), query.lon.unwrap_or(0.0)) 849 + }; 850 + 851 + // TODO: Query OpenSearch for geo aggregation 852 + let buckets: Vec<GeoHexBucket> = vec![]; 853 + 854 + Ok(Json(GeoAggregationResponse { buckets, lat, lon })) 855 + }
+36
src/http/handle_manage_event.rs
··· 270 270 }) 271 271 .collect(); 272 272 273 + // Extract all locations (addresses and geo) from the event for the form 274 + let mut event_locations: Vec<serde_json::Value> = Vec::new(); 275 + let mut event_geo_locations: Vec<serde_json::Value> = Vec::new(); 276 + 277 + for location in &community_event.locations { 278 + use atproto_record::lexicon::community::lexicon::location::LocationOrRef; 279 + match location { 280 + LocationOrRef::InlineAddress(typed_address) => { 281 + let addr = &typed_address.inner; 282 + event_locations.push(serde_json::json!({ 283 + "country": addr.country, 284 + "postal_code": addr.postal_code, 285 + "region": addr.region, 286 + "locality": addr.locality, 287 + "street": addr.street, 288 + "name": addr.name 289 + })); 290 + } 291 + LocationOrRef::InlineGeo(typed_geo) => { 292 + let geo = &typed_geo.inner; 293 + event_geo_locations.push(serde_json::json!({ 294 + "latitude": geo.latitude, 295 + "longitude": geo.longitude, 296 + "name": geo.name 297 + })); 298 + } 299 + _ => { 300 + // Skip other location types (refs, etc.) 301 + } 302 + } 303 + } 304 + 273 305 // Load event data for the details and content tabs 274 306 let (starts_form, location_form, locations_editable, location_edit_reason) = if active_tab 275 307 == "details" ··· 505 537 timezones, 506 538 default_tz, 507 539 event_links, 540 + event_locations, 541 + event_geo_locations, 508 542 locations_editable, 509 543 location_edit_reason, 510 544 delete_event_url, ··· 544 578 starts_form, 545 579 location_form, 546 580 event_links, 581 + event_locations, 582 + event_geo_locations, 547 583 popular_countries => popular, 548 584 other_countries => others, 549 585 timezones,
+1
src/http/handle_oauth_aip_login.rs
··· 116 116 "repo:community.lexicon.calendar.rsvp", 117 117 "repo:events.smokesignal.calendar.acceptance", 118 118 "repo:events.smokesignal.profile", 119 + "repo:events.smokesignal.lfg", 119 120 "rpc:tools.graze.aip.ready?aud=*", 120 121 ] 121 122 .join(" ");
+46
src/http/lfg_form.rs
··· 1 + //! LFG form constants and validation utilities. 2 + //! 3 + //! This module provides constants for the Looking For Group (LFG) feature. 4 + 5 + /// Allowed duration options in hours for LFG records. 6 + pub(crate) const ALLOWED_DURATIONS: [u32; 5] = [6, 12, 24, 48, 72]; 7 + 8 + /// Default duration in hours for new LFG records. 9 + pub(crate) const DEFAULT_DURATION_HOURS: u32 = 48; 10 + 11 + /// Maximum number of tags allowed per LFG record. 12 + pub(crate) const MAX_TAGS: usize = 10; 13 + 14 + /// Maximum length of a single tag. 15 + pub(crate) const MAX_TAG_LENGTH: usize = 64; 16 + 17 + #[cfg(test)] 18 + mod tests { 19 + use super::*; 20 + 21 + #[test] 22 + fn test_allowed_durations() { 23 + assert!(ALLOWED_DURATIONS.contains(&6)); 24 + assert!(ALLOWED_DURATIONS.contains(&12)); 25 + assert!(ALLOWED_DURATIONS.contains(&24)); 26 + assert!(ALLOWED_DURATIONS.contains(&48)); 27 + assert!(ALLOWED_DURATIONS.contains(&72)); 28 + assert!(!ALLOWED_DURATIONS.contains(&1)); 29 + assert!(!ALLOWED_DURATIONS.contains(&100)); 30 + } 31 + 32 + #[test] 33 + fn test_default_duration() { 34 + assert_eq!(DEFAULT_DURATION_HOURS, 48); 35 + } 36 + 37 + #[test] 38 + fn test_max_tags() { 39 + assert_eq!(MAX_TAGS, 10); 40 + } 41 + 42 + #[test] 43 + fn test_max_tag_length() { 44 + assert_eq!(MAX_TAG_LENGTH, 64); 45 + } 46 + }
+3
src/http/mod.rs
··· 38 38 pub mod handle_finalize_acceptance; 39 39 pub mod handle_geo_aggregation; 40 40 pub mod handle_health; 41 + pub mod handle_lfg; 42 + pub mod h3_utils; 41 43 pub mod handle_host_meta; 42 44 pub mod handle_import; 43 45 pub mod handle_index; ··· 67 69 pub mod handle_xrpc_search_events; 68 70 pub mod handler_mcp; 69 71 pub mod import_utils; 72 + pub mod lfg_form; 70 73 pub mod location_edit_status; 71 74 pub mod macros; 72 75 pub mod middleware_auth;
+11 -1
src/http/server.rs
··· 66 66 handle_finalize_acceptance::handle_finalize_acceptance, 67 67 handle_geo_aggregation::handle_geo_aggregation, 68 68 handle_health::{handle_alive, handle_ready, handle_started}, 69 + handle_lfg::{ 70 + handle_lfg_deactivate, handle_lfg_geo_aggregation, handle_lfg_get, handle_lfg_post, 71 + handle_lfg_tags_autocomplete, 72 + }, 69 73 handle_host_meta::handle_host_meta, 70 74 handle_import::{handle_import, handle_import_submit}, 71 75 handle_index::handle_index, ··· 155 159 post(handle_xrpc_link_attestation), 156 160 ) 157 161 // API endpoints 158 - .route("/api/geo-aggregation", get(handle_geo_aggregation)); 162 + .route("/api/geo-aggregation", get(handle_geo_aggregation)) 163 + .route("/api/lfg/tags", get(handle_lfg_tags_autocomplete)) 164 + .route("/api/lfg/geo-aggregation", get(handle_lfg_geo_aggregation)) 165 + // LFG routes 166 + .route("/lfg", get(handle_lfg_get)) 167 + .route("/lfg", post(handle_lfg_post)) 168 + .route("/lfg/deactivate", post(handle_lfg_deactivate)); 159 169 160 170 // Add OAuth metadata route only for AT Protocol backend 161 171 if matches!(
+1
src/lib.rs
··· 28 28 pub mod storage; 29 29 pub mod tap_processor; 30 30 pub mod task_identity_refresh; 31 + pub mod task_lfg_cleanup; 31 32 pub mod task_oauth_requests_cleanup; 32 33 pub mod task_search_indexer; 33 34 pub mod task_search_indexer_errors;
+472 -1
src/search_index.rs
··· 361 361 } 362 362 363 363 /// Index a single event 364 - async fn index_event( 364 + pub async fn index_event( 365 365 &self, 366 366 pool: &StoragePool, 367 367 identity_resolver: Arc<dyn IdentityResolver>, ··· 931 931 932 932 Ok(buckets) 933 933 } 934 + 935 + /// Search for upcoming/ongoing events near a location. 936 + /// 937 + /// Returns events within the specified radius that are either: 938 + /// - Starting in the future 939 + /// - Currently ongoing (started but not ended) 940 + /// 941 + /// # Arguments 942 + /// * `lat` - Center latitude 943 + /// * `lon` - Center longitude 944 + /// * `distance_miles` - Search radius in miles 945 + /// * `limit` - Maximum number of results 946 + pub async fn search_nearby_upcoming_events( 947 + &self, 948 + lat: f64, 949 + lon: f64, 950 + distance_miles: f64, 951 + limit: u32, 952 + ) -> Result<Vec<IndexedEvent>> { 953 + if !self.index_exists().await? { 954 + return Ok(vec![]); 955 + } 956 + 957 + let search_body = json!({ 958 + "query": { 959 + "bool": { 960 + "must": [ 961 + { 962 + "geo_distance": { 963 + "distance": format!("{}mi", distance_miles), 964 + "locations_geo": { "lat": lat, "lon": lon } 965 + } 966 + } 967 + ], 968 + "should": [ 969 + // Events starting in the future 970 + { "range": { "start_time": { "gte": "now" } } }, 971 + // Events currently ongoing 972 + { 973 + "bool": { 974 + "must": [ 975 + { "range": { "start_time": { "lte": "now" } } }, 976 + { "range": { "end_time": { "gte": "now" } } } 977 + ] 978 + } 979 + } 980 + ], 981 + "minimum_should_match": 1 982 + } 983 + }, 984 + "sort": [ 985 + { "_geo_distance": { "locations_geo": { "lat": lat, "lon": lon }, "order": "asc" } }, 986 + { "start_time": { "order": "asc" } } 987 + ], 988 + "size": limit 989 + }); 990 + 991 + let response = self 992 + .client 993 + .search(SearchParts::Index(&[INDEX_NAME])) 994 + .body(search_body) 995 + .send() 996 + .await?; 997 + 998 + if !response.status_code().is_success() { 999 + return Err(anyhow::anyhow!("Nearby events search failed")); 1000 + } 1001 + 1002 + let body = response.json::<Value>().await?; 1003 + 1004 + let events: Vec<IndexedEvent> = body["hits"]["hits"] 1005 + .as_array() 1006 + .map(|hits| { 1007 + hits.iter() 1008 + .filter_map(|hit| { 1009 + let source = &hit["_source"]; 1010 + serde_json::from_value(source.clone()).ok() 1011 + }) 1012 + .collect() 1013 + }) 1014 + .unwrap_or_default(); 1015 + 1016 + Ok(events) 1017 + } 1018 + 1019 + /// Get LFG profile geo aggregation for heatmap display. 1020 + /// 1021 + /// Returns H3 cell indices with profile counts at the specified precision. 1022 + pub async fn get_lfg_profile_geo_aggregation( 1023 + &self, 1024 + precision: u8, 1025 + center: Option<GeoCenter>, 1026 + ) -> Result<Vec<GeoHexBucket>> { 1027 + let agg_body = json!({ 1028 + "geohex_grid": { 1029 + "field": "location", 1030 + "precision": precision 1031 + } 1032 + }); 1033 + 1034 + // Build query filters - only active, non-expired profiles 1035 + let mut filters = vec![ 1036 + json!({ "term": { "active": true } }), 1037 + json!({ "range": { "ends_at": { "gte": "now" } } }), 1038 + ]; 1039 + 1040 + // Add geo_distance filter if center is provided 1041 + if let Some(c) = center { 1042 + filters.push(json!({ 1043 + "geo_distance": { 1044 + "distance": format!("{}mi", c.distance_miles), 1045 + "location": { "lat": c.lat, "lon": c.lon } 1046 + } 1047 + })); 1048 + } 1049 + 1050 + let search_body = json!({ 1051 + "size": 0, 1052 + "query": { 1053 + "bool": { "filter": filters } 1054 + }, 1055 + "aggs": { 1056 + "hex_grid": agg_body 1057 + } 1058 + }); 1059 + 1060 + let response = self 1061 + .client 1062 + .search(SearchParts::Index(&[Self::LFG_INDEX_NAME])) 1063 + .body(search_body) 1064 + .send() 1065 + .await?; 1066 + 1067 + if !response.status_code().is_success() { 1068 + return Err(anyhow::anyhow!("LFG geohex aggregation failed")); 1069 + } 1070 + 1071 + let body = response.json::<Value>().await?; 1072 + 1073 + let buckets = body["aggregations"]["hex_grid"]["buckets"] 1074 + .as_array() 1075 + .map(|arr| { 1076 + arr.iter() 1077 + .filter_map(|bucket| { 1078 + Some(GeoHexBucket { 1079 + key: bucket["key"].as_str()?.to_string(), 1080 + doc_count: bucket["doc_count"].as_u64()?, 1081 + }) 1082 + }) 1083 + .collect() 1084 + }) 1085 + .unwrap_or_default(); 1086 + 1087 + Ok(buckets) 1088 + } 1089 + 1090 + // ==================== LFG Profile Index Methods ==================== 1091 + 1092 + /// Index name for LFG profiles 1093 + const LFG_INDEX_NAME: &'static str = "smokesignal-lfg-profile"; 1094 + 1095 + /// Check if the LFG profile index exists 1096 + pub async fn lfg_index_exists(&self) -> Result<bool> { 1097 + let response = self 1098 + .client 1099 + .indices() 1100 + .exists(IndicesExistsParts::Index(&[Self::LFG_INDEX_NAME])) 1101 + .send() 1102 + .await?; 1103 + 1104 + Ok(response.status_code().is_success()) 1105 + } 1106 + 1107 + /// Create the LFG profile index with proper mappings 1108 + pub async fn create_lfg_profile_index(&self) -> Result<()> { 1109 + let exists = self.lfg_index_exists().await?; 1110 + 1111 + if exists { 1112 + tracing::debug!("Index {} already exists", Self::LFG_INDEX_NAME); 1113 + return Ok(()); 1114 + } 1115 + 1116 + let index_body = json!({ 1117 + "mappings": { 1118 + "properties": { 1119 + "aturi": { "type": "keyword" }, 1120 + "did": { "type": "keyword" }, 1121 + "location": { "type": "geo_point" }, 1122 + "tags": { "type": "keyword" }, 1123 + "starts_at": { "type": "date" }, 1124 + "ends_at": { "type": "date" }, 1125 + "active": { "type": "boolean" }, 1126 + "created_at": { "type": "date" } 1127 + } 1128 + } 1129 + }); 1130 + 1131 + let response = self 1132 + .client 1133 + .indices() 1134 + .create(IndicesCreateParts::Index(Self::LFG_INDEX_NAME)) 1135 + .body(index_body) 1136 + .send() 1137 + .await?; 1138 + 1139 + if !response.status_code().is_success() { 1140 + let error_body = response.text().await?; 1141 + return Err(anyhow::anyhow!("Failed to create LFG index: {}", error_body)); 1142 + } 1143 + 1144 + tracing::info!("Created OpenSearch index {}", Self::LFG_INDEX_NAME); 1145 + Ok(()) 1146 + } 1147 + 1148 + /// Index an LFG profile 1149 + pub async fn index_lfg_profile( 1150 + &self, 1151 + aturi: &str, 1152 + did: &str, 1153 + lat: f64, 1154 + lon: f64, 1155 + tags: &[String], 1156 + starts_at: &chrono::DateTime<chrono::Utc>, 1157 + ends_at: &chrono::DateTime<chrono::Utc>, 1158 + created_at: &chrono::DateTime<chrono::Utc>, 1159 + active: bool, 1160 + ) -> Result<()> { 1161 + // Ensure index exists 1162 + self.create_lfg_profile_index().await?; 1163 + 1164 + let doc = json!({ 1165 + "aturi": aturi, 1166 + "did": did, 1167 + "location": { "lat": lat, "lon": lon }, 1168 + "tags": tags, 1169 + "starts_at": starts_at.to_rfc3339(), 1170 + "ends_at": ends_at.to_rfc3339(), 1171 + "created_at": created_at.to_rfc3339(), 1172 + "active": active 1173 + }); 1174 + 1175 + let response = self 1176 + .client 1177 + .index(IndexParts::IndexId(Self::LFG_INDEX_NAME, aturi)) 1178 + .body(doc) 1179 + .send() 1180 + .await?; 1181 + 1182 + if !response.status_code().is_success() { 1183 + let error_body = response.text().await?; 1184 + tracing::error!("Failed to index LFG profile {}: {}", aturi, error_body); 1185 + return Err(anyhow::anyhow!("Failed to index LFG profile")); 1186 + } 1187 + 1188 + Ok(()) 1189 + } 1190 + 1191 + /// Delete an LFG profile from the index 1192 + pub async fn delete_lfg_profile(&self, aturi: &str) -> Result<()> { 1193 + let response = self 1194 + .client 1195 + .delete(DeleteParts::IndexId(Self::LFG_INDEX_NAME, aturi)) 1196 + .send() 1197 + .await?; 1198 + 1199 + if !response.status_code().is_success() && response.status_code() != 404 { 1200 + return Err(anyhow::anyhow!("Failed to delete LFG profile")); 1201 + } 1202 + 1203 + Ok(()) 1204 + } 1205 + 1206 + /// Search for nearby LFG profiles 1207 + /// 1208 + /// Returns LFG profiles within the specified radius that have overlapping tags. 1209 + pub async fn search_nearby_lfg_profiles( 1210 + &self, 1211 + lat: f64, 1212 + lon: f64, 1213 + distance_miles: f64, 1214 + tags: &[String], 1215 + exclude_did: Option<&str>, 1216 + limit: u32, 1217 + ) -> Result<Vec<IndexedLfgProfile>> { 1218 + let must_clauses: Vec<Value> = vec![ 1219 + json!({ 1220 + "geo_distance": { 1221 + "distance": format!("{}mi", distance_miles), 1222 + "location": { "lat": lat, "lon": lon } 1223 + } 1224 + }), 1225 + json!({ "term": { "active": true } }), 1226 + json!({ "range": { "ends_at": { "gte": "now" } } }), 1227 + ]; 1228 + 1229 + let mut should_clauses = Vec::new(); 1230 + if !tags.is_empty() { 1231 + should_clauses.push(json!({ "terms": { "tags": tags } })); 1232 + } 1233 + 1234 + let mut must_not = Vec::new(); 1235 + if let Some(did) = exclude_did { 1236 + must_not.push(json!({ "term": { "did": did } })); 1237 + } 1238 + 1239 + let mut query = json!({ 1240 + "bool": { 1241 + "must": must_clauses 1242 + } 1243 + }); 1244 + 1245 + if !should_clauses.is_empty() { 1246 + query["bool"]["should"] = json!(should_clauses); 1247 + query["bool"]["minimum_should_match"] = json!(1); 1248 + } 1249 + 1250 + if !must_not.is_empty() { 1251 + query["bool"]["must_not"] = json!(must_not); 1252 + } 1253 + 1254 + let search_body = json!({ 1255 + "query": query, 1256 + "sort": [ 1257 + { "_score": { "order": "desc" } }, 1258 + { "_geo_distance": { "location": { "lat": lat, "lon": lon }, "order": "asc" } } 1259 + ], 1260 + "size": limit 1261 + }); 1262 + 1263 + let response = self 1264 + .client 1265 + .search(SearchParts::Index(&[Self::LFG_INDEX_NAME])) 1266 + .body(search_body) 1267 + .send() 1268 + .await?; 1269 + 1270 + if !response.status_code().is_success() { 1271 + return Err(anyhow::anyhow!("LFG profile search failed")); 1272 + } 1273 + 1274 + let body = response.json::<Value>().await?; 1275 + 1276 + let profiles: Vec<IndexedLfgProfile> = body["hits"]["hits"] 1277 + .as_array() 1278 + .map(|hits| { 1279 + hits.iter() 1280 + .filter_map(|hit| { 1281 + let source = &hit["_source"]; 1282 + serde_json::from_value(source.clone()).ok() 1283 + }) 1284 + .collect() 1285 + }) 1286 + .unwrap_or_default(); 1287 + 1288 + Ok(profiles) 1289 + } 1290 + 1291 + /// Get popular tags from active LFG profiles. 1292 + /// 1293 + /// Uses a terms aggregation to find the most commonly used tags across 1294 + /// all active (not expired) LFG profiles. 1295 + /// 1296 + /// Returns a vector of (tag, count) pairs sorted by count descending. 1297 + pub async fn get_popular_lfg_tags(&self, limit: u32) -> Result<Vec<(String, i64)>> { 1298 + let query = json!({ 1299 + "size": 0, 1300 + "query": { 1301 + "bool": { 1302 + "filter": [ 1303 + { "term": { "active": true } }, 1304 + { "range": { "ends_at": { "gt": "now" } } } 1305 + ] 1306 + } 1307 + }, 1308 + "aggs": { 1309 + "popular_tags": { 1310 + "terms": { 1311 + "field": "tags", 1312 + "size": limit 1313 + } 1314 + } 1315 + } 1316 + }); 1317 + 1318 + let response = self 1319 + .client 1320 + .search(opensearch::SearchParts::Index(&[Self::LFG_INDEX_NAME])) 1321 + .body(query) 1322 + .send() 1323 + .await?; 1324 + 1325 + if !response.status_code().is_success() { 1326 + let error_body = response.text().await?; 1327 + return Err(anyhow::anyhow!( 1328 + "Failed to get popular LFG tags: {}", 1329 + error_body 1330 + )); 1331 + } 1332 + 1333 + let body = response.json::<Value>().await?; 1334 + 1335 + let tags: Vec<(String, i64)> = body["aggregations"]["popular_tags"]["buckets"] 1336 + .as_array() 1337 + .map(|buckets| { 1338 + buckets 1339 + .iter() 1340 + .filter_map(|bucket| { 1341 + let key = bucket["key"].as_str()?.to_string(); 1342 + let count = bucket["doc_count"].as_i64()?; 1343 + Some((key, count)) 1344 + }) 1345 + .collect() 1346 + }) 1347 + .unwrap_or_default(); 1348 + 1349 + Ok(tags) 1350 + } 1351 + 1352 + /// Deactivate expired LFG profiles in the index 1353 + /// 1354 + /// This updates the `active` field to false for profiles where `ends_at` < now. 1355 + pub async fn deactivate_expired_lfg_profiles(&self) -> Result<u64> { 1356 + let update_body = json!({ 1357 + "script": { 1358 + "source": "ctx._source.active = false", 1359 + "lang": "painless" 1360 + }, 1361 + "query": { 1362 + "bool": { 1363 + "must": [ 1364 + { "term": { "active": true } }, 1365 + { "range": { "ends_at": { "lt": "now" } } } 1366 + ] 1367 + } 1368 + } 1369 + }); 1370 + 1371 + let response = self 1372 + .client 1373 + .update_by_query(opensearch::UpdateByQueryParts::Index(&[Self::LFG_INDEX_NAME])) 1374 + .body(update_body) 1375 + .send() 1376 + .await?; 1377 + 1378 + if !response.status_code().is_success() { 1379 + let error_body = response.text().await?; 1380 + return Err(anyhow::anyhow!("Failed to deactivate expired LFG profiles: {}", error_body)); 1381 + } 1382 + 1383 + let body = response.json::<Value>().await?; 1384 + let updated = body["updated"].as_u64().unwrap_or(0); 1385 + 1386 + if updated > 0 { 1387 + tracing::info!("Deactivated {} expired LFG profiles", updated); 1388 + } 1389 + 1390 + Ok(updated) 1391 + } 1392 + } 1393 + 1394 + /// Indexed LFG profile from OpenSearch 1395 + #[derive(Debug, Serialize, Deserialize)] 1396 + pub struct IndexedLfgProfile { 1397 + pub aturi: String, 1398 + pub did: String, 1399 + pub location: GeoPoint, 1400 + pub tags: Vec<String>, 1401 + pub starts_at: String, 1402 + pub ends_at: String, 1403 + pub active: bool, 1404 + pub created_at: String, 934 1405 } 935 1406 936 1407 #[cfg(test)]
+16 -2
src/stats.rs
··· 28 28 pub(crate) struct NetworkStats { 29 29 pub event_count: i64, 30 30 pub rsvp_count: i64, 31 + pub lfg_identities_count: i64, 32 + pub lfg_locations_count: i64, 31 33 } 32 34 33 35 impl NetworkStats { ··· 82 84 static IN_MEMORY_CACHE: once_cell::sync::Lazy<Arc<RwLock<InMemoryCache>>> = 83 85 once_cell::sync::Lazy::new(|| Arc::new(RwLock::new(InMemoryCache::new()))); 84 86 85 - /// Query database for event and RSVP counts 87 + /// Query database for event, RSVP, and LFG counts 86 88 async fn query_stats(pool: &StoragePool) -> Result<NetworkStats, StatsError> { 87 89 let row = sqlx::query( 88 90 r#" 89 91 SELECT 90 92 (SELECT COUNT(*) FROM events) as event_count, 91 - (SELECT COUNT(*) FROM rsvps) as rsvp_count 93 + (SELECT COUNT(*) FROM rsvps) as rsvp_count, 94 + (SELECT COUNT(DISTINCT did) FROM atproto_records 95 + WHERE collection = 'events.smokesignal.lfg' 96 + AND (record->>'active')::boolean = true) as lfg_identities_count, 97 + (SELECT COUNT(DISTINCT record->'location'->>'value') FROM atproto_records 98 + WHERE collection = 'events.smokesignal.lfg' 99 + AND (record->>'active')::boolean = true) as lfg_locations_count 92 100 "#, 93 101 ) 94 102 .fetch_one(pool) ··· 101 109 .map_err(|e| StatsError::DatabaseError(e.to_string()))?, 102 110 rsvp_count: row 103 111 .try_get("rsvp_count") 112 + .map_err(|e| StatsError::DatabaseError(e.to_string()))?, 113 + lfg_identities_count: row 114 + .try_get("lfg_identities_count") 115 + .map_err(|e| StatsError::DatabaseError(e.to_string()))?, 116 + lfg_locations_count: row 117 + .try_get("lfg_locations_count") 104 118 .map_err(|e| StatsError::DatabaseError(e.to_string()))?, 105 119 }) 106 120 }
+16 -10
src/storage/atproto_record.rs
··· 183 183 .await 184 184 .map_err(StorageError::UnableToExecuteQuery)?; 185 185 186 - // Collect all suggestions with timestamps for sorting 187 - let mut suggestions_with_time: Vec<(DateTime<Utc>, LocationSuggestion)> = Vec::new(); 186 + // Collect all suggestions with (priority, timestamp) for sorting 187 + // Priority: 0 = beaconbits/dropanchor (higher priority), 1 = smokesignal events 188 + let mut suggestions_with_priority: Vec<(u8, DateTime<Utc>, LocationSuggestion)> = Vec::new(); 188 189 189 - // Process atproto records (beaconbits/dropanchor) 190 + // Process atproto records (beaconbits/dropanchor) - priority 0 190 191 for (aturi, indexed_at, record) in atproto_rows { 191 192 let r = &record.0; 192 193 // Try addressDetails (beaconbits) first, then address (dropanchor) ··· 229 230 .and_then(|v| v.as_str()) 230 231 .map(String::from), 231 232 }; 232 - suggestions_with_time.push((indexed_at, suggestion)); 233 + suggestions_with_priority.push((0, indexed_at, suggestion)); 233 234 } 234 235 235 - // Process event locations 236 + // Process event locations - priority 1 (lower than beaconbits/dropanchor) 236 237 for (aturi, updated_at, record) in event_rows { 237 238 let timestamp = updated_at.unwrap_or_else(Utc::now); 238 239 let locations = extract_locations_from_event(&aturi, &record.0); 239 240 for suggestion in locations { 240 - suggestions_with_time.push((timestamp, suggestion)); 241 + suggestions_with_priority.push((1, timestamp, suggestion)); 241 242 } 242 243 } 243 244 244 - // Sort by timestamp descending (most recent first) 245 - suggestions_with_time.sort_by(|a, b| b.0.cmp(&a.0)); 245 + // Sort by priority ascending (0 first), then by timestamp descending 246 + suggestions_with_priority.sort_by(|a, b| { 247 + match a.0.cmp(&b.0) { 248 + std::cmp::Ordering::Equal => b.1.cmp(&a.1), // Same priority: newer first 249 + other => other, // Different priority: lower first 250 + } 251 + }); 246 252 247 253 // Collect into OrderSet (deduplicates based on location fields) 248 - let suggestions: OrderSet<LocationSuggestion> = suggestions_with_time 254 + let suggestions: OrderSet<LocationSuggestion> = suggestions_with_priority 249 255 .into_iter() 250 - .map(|(_, s)| s) 256 + .map(|(_, _, s)| s) 251 257 .collect(); 252 258 253 259 Ok(suggestions)
+94
src/storage/lfg.rs
··· 1 + //! Storage module for LFG (Looking For Group) records. 2 + //! 3 + //! This module provides query helpers for LFG records stored in the `atproto_records` table. 4 + //! For writes, use `atproto_record_upsert()` and `atproto_record_delete()` from the 5 + //! `atproto_record` module. 6 + //! 7 + //! Records are stored as `AtprotoRecord` and can be deserialized to `Lfg` using: 8 + //! ```ignore 9 + //! let lfg: Lfg = serde_json::from_value(record.record.0.clone())?; 10 + //! ``` 11 + 12 + use super::atproto_record::AtprotoRecord; 13 + use super::errors::StorageError; 14 + use super::StoragePool; 15 + use crate::atproto::lexicon::lfg::NSID; 16 + 17 + /// Get the active LFG record for a DID from atproto_records. 18 + /// 19 + /// Returns the most recent active LFG record for the given DID, or None if 20 + /// no active record exists. 21 + pub async fn lfg_get_active_by_did( 22 + pool: &StoragePool, 23 + did: &str, 24 + ) -> Result<Option<AtprotoRecord>, StorageError> { 25 + let record = sqlx::query_as::<_, AtprotoRecord>( 26 + r#" 27 + SELECT aturi, did, cid, collection, indexed_at, record 28 + FROM atproto_records 29 + WHERE did = $1 30 + AND collection = $2 31 + AND (record->>'active')::boolean = true 32 + AND (record->>'endsAt')::timestamptz > NOW() 33 + ORDER BY indexed_at DESC 34 + LIMIT 1 35 + "#, 36 + ) 37 + .bind(did) 38 + .bind(NSID) 39 + .fetch_optional(pool) 40 + .await 41 + .map_err(StorageError::UnableToExecuteQuery)?; 42 + 43 + Ok(record) 44 + } 45 + 46 + /// Get an LFG record by AT-URI from atproto_records. 47 + pub async fn lfg_get_by_aturi( 48 + pool: &StoragePool, 49 + aturi: &str, 50 + ) -> Result<Option<AtprotoRecord>, StorageError> { 51 + let record = sqlx::query_as::<_, AtprotoRecord>( 52 + r#" 53 + SELECT aturi, did, cid, collection, indexed_at, record 54 + FROM atproto_records 55 + WHERE aturi = $1 56 + AND collection = $2 57 + "#, 58 + ) 59 + .bind(aturi) 60 + .bind(NSID) 61 + .fetch_optional(pool) 62 + .await 63 + .map_err(StorageError::UnableToExecuteQuery)?; 64 + 65 + Ok(record) 66 + } 67 + 68 + /// Get all LFG records for a DID (including inactive/expired). 69 + /// 70 + /// Used for tag history lookups. 71 + pub async fn lfg_get_all_by_did( 72 + pool: &StoragePool, 73 + did: &str, 74 + limit: i64, 75 + ) -> Result<Vec<AtprotoRecord>, StorageError> { 76 + let records = sqlx::query_as::<_, AtprotoRecord>( 77 + r#" 78 + SELECT aturi, did, cid, collection, indexed_at, record 79 + FROM atproto_records 80 + WHERE did = $1 81 + AND collection = $2 82 + ORDER BY indexed_at DESC 83 + LIMIT $3 84 + "#, 85 + ) 86 + .bind(did) 87 + .bind(NSID) 88 + .bind(limit) 89 + .fetch_all(pool) 90 + .await 91 + .map_err(StorageError::UnableToExecuteQuery)?; 92 + 93 + Ok(records) 94 + }
+1
src/storage/mod.rs
··· 8 8 pub mod errors; 9 9 pub mod event; 10 10 pub mod identity_profile; 11 + pub mod lfg; 11 12 pub mod notification; 12 13 pub mod oauth; 13 14 pub mod private_event_content;
+1
src/tap_processor.rs
··· 207 207 | "community.lexicon.calendar.event" 208 208 | "events.smokesignal.profile" 209 209 | "events.smokesignal.calendar.acceptance" 210 + | "events.smokesignal.lfg" 210 211 | "app.beaconbits.bookmark.item" 211 212 | "app.beaconbits.beacon" 212 213 | "app.dropanchor.checkin"
+163
src/task_lfg_cleanup.rs
··· 1 + //! LFG (Looking For Group) cleanup background task. 2 + //! 3 + //! This task runs periodically to deactivate expired LFG records in both 4 + //! the database and OpenSearch index. 5 + 6 + use anyhow::Result; 7 + use chrono::{Duration, Utc}; 8 + use tokio::time::{Instant, sleep}; 9 + use tokio_util::sync::CancellationToken; 10 + 11 + use crate::search_index::SearchIndexManager; 12 + use crate::storage::StoragePool; 13 + use crate::atproto::lexicon::lfg::NSID; 14 + 15 + /// Configuration for the LFG cleanup task. 16 + pub struct LfgCleanupTaskConfig { 17 + /// How often to run the cleanup (default: 1 hour) 18 + pub sleep_interval: Duration, 19 + } 20 + 21 + impl Default for LfgCleanupTaskConfig { 22 + fn default() -> Self { 23 + Self { 24 + sleep_interval: Duration::hours(1), 25 + } 26 + } 27 + } 28 + 29 + /// Background task that deactivates expired LFG records. 30 + pub struct LfgCleanupTask { 31 + pub config: LfgCleanupTaskConfig, 32 + pub storage_pool: StoragePool, 33 + pub search_index: Option<SearchIndexManager>, 34 + pub cancellation_token: CancellationToken, 35 + } 36 + 37 + impl LfgCleanupTask { 38 + /// Creates a new LFG cleanup task. 39 + #[must_use] 40 + pub fn new( 41 + config: LfgCleanupTaskConfig, 42 + storage_pool: StoragePool, 43 + search_index: Option<SearchIndexManager>, 44 + cancellation_token: CancellationToken, 45 + ) -> Self { 46 + Self { 47 + config, 48 + storage_pool, 49 + search_index, 50 + cancellation_token, 51 + } 52 + } 53 + 54 + /// Runs the LFG cleanup task as a long-running process. 55 + /// 56 + /// This task: 57 + /// 1. Deactivates expired LFG records in the database 58 + /// 2. Updates the OpenSearch index to reflect expired records 59 + /// 60 + /// # Errors 61 + /// Returns an error if the sleep interval cannot be converted, or if there's 62 + /// a problem cleaning up expired records. 63 + pub async fn run(&self) -> Result<()> { 64 + tracing::info!("LfgCleanupTask started"); 65 + 66 + let interval = self.config.sleep_interval.to_std()?; 67 + 68 + let sleeper = sleep(interval); 69 + tokio::pin!(sleeper); 70 + 71 + loop { 72 + tokio::select! { 73 + () = self.cancellation_token.cancelled() => { 74 + break; 75 + }, 76 + () = &mut sleeper => { 77 + if let Err(err) = self.cleanup_expired_lfg_records().await { 78 + tracing::error!("LfgCleanupTask failed: {}", err); 79 + } 80 + sleeper.as_mut().reset(Instant::now() + interval); 81 + } 82 + } 83 + } 84 + 85 + tracing::info!("LfgCleanupTask stopped"); 86 + 87 + Ok(()) 88 + } 89 + 90 + /// Cleanup expired LFG records. 91 + async fn cleanup_expired_lfg_records(&self) -> Result<()> { 92 + let now = Utc::now(); 93 + 94 + tracing::debug!("Starting cleanup of expired LFG records"); 95 + 96 + // Step 1: Update expired records in the database 97 + let db_result = self.deactivate_expired_in_database(&now).await?; 98 + 99 + // Step 2: Update expired records in OpenSearch 100 + let os_result = self.deactivate_expired_in_opensearch().await?; 101 + 102 + if db_result > 0 || os_result > 0 { 103 + tracing::info!( 104 + database_updated = db_result, 105 + opensearch_updated = os_result, 106 + "Cleaned up expired LFG records" 107 + ); 108 + } else { 109 + tracing::debug!("No expired LFG records to clean up"); 110 + } 111 + 112 + Ok(()) 113 + } 114 + 115 + /// Deactivate expired LFG records in the database. 116 + async fn deactivate_expired_in_database( 117 + &self, 118 + now: &chrono::DateTime<Utc>, 119 + ) -> Result<u64> { 120 + // Query for active LFG records that have expired 121 + let result = sqlx::query( 122 + r#" 123 + UPDATE atproto_records 124 + SET record = jsonb_set(record, '{active}', 'false') 125 + WHERE collection = $1 126 + AND (record->>'active')::boolean = true 127 + AND (record->>'endsAt')::timestamptz < $2 128 + "#, 129 + ) 130 + .bind(NSID) 131 + .bind(now) 132 + .execute(&self.storage_pool) 133 + .await?; 134 + 135 + Ok(result.rows_affected()) 136 + } 137 + 138 + /// Deactivate expired LFG profiles in OpenSearch. 139 + async fn deactivate_expired_in_opensearch(&self) -> Result<u64> { 140 + let Some(ref search_index) = self.search_index else { 141 + return Ok(0); 142 + }; 143 + 144 + match search_index.deactivate_expired_lfg_profiles().await { 145 + Ok(count) => Ok(count), 146 + Err(err) => { 147 + tracing::warn!("Failed to deactivate expired LFG profiles in OpenSearch: {}", err); 148 + Ok(0) 149 + } 150 + } 151 + } 152 + } 153 + 154 + #[cfg(test)] 155 + mod tests { 156 + use super::*; 157 + 158 + #[test] 159 + fn test_default_config() { 160 + let config = LfgCleanupTaskConfig::default(); 161 + assert_eq!(config.sleep_interval, Duration::hours(1)); 162 + } 163 + }
+120 -124
src/task_search_indexer.rs
··· 1 1 use anyhow::Result; 2 - use atproto_attestation::create_dagbor_cid; 3 2 use atproto_identity::{model::Document, resolve::IdentityResolver, traits::DidDocumentStorage}; 4 - use atproto_record::lexicon::app::bsky::richtext::facet::{Facet, FacetFeature}; 5 3 use atproto_record::lexicon::community::lexicon::calendar::event::NSID as LexiconCommunityEventNSID; 6 4 use opensearch::{ 7 5 DeleteParts, IndexParts, OpenSearch, 8 6 http::transport::Transport, 9 7 indices::{IndicesCreateParts, IndicesExistsParts}, 10 8 }; 11 - use serde::Deserialize; 12 9 use serde_json::{Value, json}; 13 10 use std::sync::Arc; 14 11 12 + use crate::atproto::lexicon::lfg::{Lfg, NSID as LFG_NSID}; 15 13 use crate::atproto::lexicon::profile::{Profile, NSID as PROFILE_NSID}; 16 14 use crate::atproto::utils::get_profile_hashtags; 15 + use crate::search_index::SearchIndexManager; 16 + use crate::storage::event::event_get; 17 + use crate::storage::StoragePool; 17 18 use crate::task_search_indexer_errors::SearchIndexerError; 18 19 19 20 /// Build an AT URI with pre-allocated capacity to avoid format! overhead. ··· 30 31 uri 31 32 } 32 33 33 - /// Generate a DAG-CBOR CID from a JSON value. 34 - /// 35 - /// Creates a CIDv1 with DAG-CBOR codec (0x71) and SHA-256 hash (0x12), 36 - /// following the AT Protocol specification for content addressing. 37 - fn generate_location_cid(value: &Value) -> Result<String, SearchIndexerError> { 38 - let cid = create_dagbor_cid(value).map_err(|e| SearchIndexerError::CidGenerationFailed { 39 - error: e.to_string(), 40 - })?; 41 - Ok(cid.to_string()) 42 - } 43 - 44 - /// A lightweight event struct for search indexing. 45 - /// 46 - /// Uses serde_json::Value for locations to avoid deserialization errors 47 - /// when event data contains location types not supported by the LocationOrRef enum. 48 - #[derive(Deserialize)] 49 - struct IndexableEvent { 50 - name: String, 51 - description: String, 52 - #[serde(rename = "createdAt")] 53 - created_at: chrono::DateTime<chrono::Utc>, 54 - #[serde(rename = "startsAt")] 55 - starts_at: Option<chrono::DateTime<chrono::Utc>>, 56 - #[serde(rename = "endsAt")] 57 - ends_at: Option<chrono::DateTime<chrono::Utc>>, 58 - #[serde(rename = "descriptionFacets")] 59 - facets: Option<Vec<Facet>>, 60 - /// Locations stored as raw JSON values for CID generation. 61 - #[serde(default)] 62 - locations: Vec<Value>, 63 - } 64 - 65 - impl IndexableEvent { 66 - /// Extract hashtags from the event's facets 67 - fn get_hashtags(&self) -> Vec<String> { 68 - self.facets 69 - .as_ref() 70 - .map(|facets| { 71 - facets 72 - .iter() 73 - .flat_map(|facet| { 74 - facet.features.iter().filter_map(|feature| { 75 - if let FacetFeature::Tag(tag) = feature { 76 - Some(tag.tag.clone()) 77 - } else { 78 - None 79 - } 80 - }) 81 - }) 82 - .collect() 83 - }) 84 - .unwrap_or_default() 85 - } 86 - } 87 - 88 34 const EVENTS_INDEX_NAME: &str = "smokesignal-events"; 89 35 const PROFILES_INDEX_NAME: &str = "smokesignal-profiles"; 36 + const LFG_INDEX_NAME: &str = "smokesignal-lfg-profile"; 90 37 91 38 pub struct SearchIndexer { 92 39 client: Arc<OpenSearch>, 40 + pool: StoragePool, 93 41 identity_resolver: Arc<dyn IdentityResolver>, 94 42 document_storage: Arc<dyn DidDocumentStorage + Send + Sync>, 43 + event_index_manager: SearchIndexManager, 95 44 } 96 45 97 46 impl SearchIndexer { ··· 100 49 /// # Arguments 101 50 /// 102 51 /// * `endpoint` - OpenSearch endpoint URL 52 + /// * `pool` - Database connection pool for fetching events 103 53 /// * `identity_resolver` - Resolver for DID identities 104 54 /// * `document_storage` - Storage for DID documents 105 55 pub async fn new( 106 56 endpoint: &str, 57 + pool: StoragePool, 107 58 identity_resolver: Arc<dyn IdentityResolver>, 108 59 document_storage: Arc<dyn DidDocumentStorage + Send + Sync>, 109 60 ) -> Result<Self> { 110 61 let transport = Transport::single_node(endpoint)?; 111 62 let client = Arc::new(OpenSearch::new(transport)); 63 + let event_index_manager = SearchIndexManager::new(endpoint)?; 112 64 113 65 let indexer = Self { 114 66 client, 67 + pool, 115 68 identity_resolver, 116 69 document_storage, 70 + event_index_manager, 117 71 }; 118 72 119 73 indexer.ensure_index().await?; ··· 126 80 self.ensure_events_index().await?; 127 81 // Ensure profiles index 128 82 self.ensure_profiles_index().await?; 83 + // Ensure LFG profiles index 84 + self.ensure_lfg_profiles_index().await?; 129 85 Ok(()) 130 86 } 131 87 ··· 151 107 "description": { "type": "text" }, 152 108 "tags": { "type": "keyword" }, 153 109 "location_cids": { "type": "keyword" }, 110 + "locations_geo": { "type": "geo_point" }, 154 111 "start_time": { "type": "date" }, 155 112 "end_time": { "type": "date" }, 156 113 "created_at": { "type": "date" }, ··· 222 179 Ok(()) 223 180 } 224 181 182 + async fn ensure_lfg_profiles_index(&self) -> Result<()> { 183 + let exists_response = self 184 + .client 185 + .indices() 186 + .exists(IndicesExistsParts::Index(&[LFG_INDEX_NAME])) 187 + .send() 188 + .await?; 189 + 190 + if exists_response.status_code().is_success() { 191 + tracing::info!("OpenSearch index {} already exists", LFG_INDEX_NAME); 192 + return Ok(()); 193 + } 194 + 195 + let index_body = json!({ 196 + "mappings": { 197 + "properties": { 198 + "aturi": { "type": "keyword" }, 199 + "did": { "type": "keyword" }, 200 + "location": { "type": "geo_point" }, 201 + "tags": { "type": "keyword" }, 202 + "starts_at": { "type": "date" }, 203 + "ends_at": { "type": "date" }, 204 + "active": { "type": "boolean" }, 205 + "created_at": { "type": "date" } 206 + } 207 + }, 208 + }); 209 + 210 + let response = self 211 + .client 212 + .indices() 213 + .create(IndicesCreateParts::Index(LFG_INDEX_NAME)) 214 + .body(index_body) 215 + .send() 216 + .await?; 217 + 218 + if response.status_code().is_success() { 219 + tracing::info!("Created OpenSearch index {}", LFG_INDEX_NAME); 220 + } else { 221 + let error_body = response.text().await?; 222 + return Err(SearchIndexerError::IndexCreationFailed { error_body }.into()); 223 + } 224 + 225 + Ok(()) 226 + } 227 + 225 228 /// Index a commit event (create or update). 226 229 /// 227 230 /// Dispatches to the appropriate indexer based on collection type. ··· 236 239 match collection { 237 240 "community.lexicon.calendar.event" => self.index_event(did, rkey, record).await, 238 241 c if c == PROFILE_NSID => self.index_profile(did, rkey, record).await, 242 + c if c == LFG_NSID => self.index_lfg_profile(did, rkey, record).await, 239 243 _ => Ok(()), 240 244 } 241 245 } ··· 247 251 match collection { 248 252 "community.lexicon.calendar.event" => self.delete_event(did, rkey).await, 249 253 c if c == PROFILE_NSID => self.delete_profile(did, rkey).await, 254 + c if c == LFG_NSID => self.delete_lfg_profile(did, rkey).await, 250 255 _ => Ok(()), 251 256 } 252 257 } 253 258 254 - async fn index_event(&self, did: &str, rkey: &str, record: Value) -> Result<()> { 255 - let event: IndexableEvent = serde_json::from_value(record)?; 256 - 257 - let document = self.ensure_identity_stored(did).await?; 258 - let handle = document.handles().unwrap_or("invalid.handle"); 259 - 259 + async fn index_event(&self, did: &str, rkey: &str, _record: Value) -> Result<()> { 260 260 let aturi = build_aturi(did, LexiconCommunityEventNSID, rkey); 261 261 262 - // Extract fields from the IndexableEvent struct 263 - let name = &event.name; 264 - let description = &event.description; 265 - let created_at = &event.created_at; 266 - let starts_at = &event.starts_at; 267 - let ends_at = &event.ends_at; 268 - 269 - // Extract hashtags from facets 270 - let tags = event.get_hashtags(); 271 - 272 - // Generate CIDs for each location 273 - let location_cids: Vec<String> = event 274 - .locations 275 - .iter() 276 - .filter_map(|loc| generate_location_cid(loc).ok()) 277 - .collect(); 278 - 279 - let mut doc = json!({ 280 - "did": did, 281 - "handle": handle, 282 - "name": name, 283 - "description": description, 284 - "tags": tags, 285 - "location_cids": location_cids, 286 - "created_at": json!(created_at), 287 - "updated_at": json!(chrono::Utc::now()) 288 - }); 289 - 290 - // Add optional time fields 291 - if let Some(start) = starts_at { 292 - doc["start_time"] = json!(start); 293 - } 294 - if let Some(end) = ends_at { 295 - doc["end_time"] = json!(end); 296 - } 297 - 298 - let response = self 299 - .client 300 - .index(IndexParts::IndexId(EVENTS_INDEX_NAME, &aturi)) 301 - .body(doc) 302 - .send() 303 - .await?; 304 - 305 - if response.status_code().is_success() { 306 - tracing::debug!("Indexed event {} for DID {}", rkey, did); 307 - } else { 308 - let error_body = response.text().await?; 309 - tracing::error!("Failed to index event: {}", error_body); 262 + // Fetch the event from the database and delegate to SearchIndexManager 263 + // This ensures we use the same indexing logic as the web handlers 264 + match event_get(&self.pool, &aturi).await { 265 + Ok(event) => { 266 + self.event_index_manager 267 + .index_event(&self.pool, self.identity_resolver.clone(), &event) 268 + .await?; 269 + tracing::debug!("Indexed event {} for DID {}", rkey, did); 270 + } 271 + Err(err) => { 272 + // Event might not be in the database yet if content fetcher hasn't processed it 273 + tracing::warn!( 274 + "Could not fetch event {} for indexing: {}. It may be indexed on next update.", 275 + aturi, 276 + err 277 + ); 278 + } 310 279 } 311 280 312 281 Ok(()) ··· 315 284 async fn delete_event(&self, did: &str, rkey: &str) -> Result<()> { 316 285 let aturi = build_aturi(did, LexiconCommunityEventNSID, rkey); 317 286 318 - let response = self 319 - .client 320 - .delete(DeleteParts::IndexId(EVENTS_INDEX_NAME, &aturi)) 321 - .send() 322 - .await?; 287 + // Delegate to SearchIndexManager for consistent deletion logic 288 + self.event_index_manager.delete_indexed_event(&aturi).await?; 323 289 324 - if response.status_code().is_success() || response.status_code() == 404 { 325 - tracing::debug!("Deleted event {} for DID {} from search index", rkey, did); 326 - } else { 327 - let error_body = response.text().await?; 328 - tracing::error!("Failed to delete event from index: {}", error_body); 329 - } 330 - 290 + tracing::debug!("Deleted event {} for DID {} from search index", rkey, did); 331 291 Ok(()) 332 292 } 333 293 ··· 389 349 tracing::error!("Failed to delete profile from index: {}", error_body); 390 350 } 391 351 352 + Ok(()) 353 + } 354 + 355 + async fn index_lfg_profile(&self, did: &str, rkey: &str, record: Value) -> Result<()> { 356 + let lfg: Lfg = serde_json::from_value(record)?; 357 + let aturi = build_aturi(did, LFG_NSID, rkey); 358 + 359 + // Extract coordinates from location 360 + let (lat, lon) = lfg.get_coordinates().unwrap_or((0.0, 0.0)); 361 + 362 + // Delegate to SearchIndexManager for consistent indexing logic 363 + self.event_index_manager 364 + .index_lfg_profile( 365 + &aturi, 366 + did, 367 + lat, 368 + lon, 369 + &lfg.tags, 370 + &lfg.starts_at, 371 + &lfg.ends_at, 372 + &lfg.created_at, 373 + lfg.active, 374 + ) 375 + .await?; 376 + 377 + tracing::debug!("Indexed LFG profile {} for DID {}", rkey, did); 378 + Ok(()) 379 + } 380 + 381 + async fn delete_lfg_profile(&self, did: &str, rkey: &str) -> Result<()> { 382 + let aturi = build_aturi(did, LFG_NSID, rkey); 383 + 384 + // Delegate to SearchIndexManager for consistent deletion logic 385 + self.event_index_manager.delete_lfg_profile(&aturi).await?; 386 + 387 + tracing::debug!("Deleted LFG profile {} for DID {} from search index", rkey, did); 392 388 Ok(()) 393 389 } 394 390
-7
src/task_search_indexer_errors.rs
··· 12 12 /// and the operation fails with a server error response. 13 13 #[error("error-smokesignal-search-indexer-1 Failed to create index: {error_body}")] 14 14 IndexCreationFailed { error_body: String }, 15 - 16 - /// Error when CID generation fails. 17 - /// 18 - /// This error occurs when serializing location data to DAG-CBOR 19 - /// or generating the multihash for a CID fails. 20 - #[error("error-smokesignal-search-indexer-2 Failed to generate CID: {error}")] 21 - CidGenerationFailed { error: String }, 22 15 }
+24 -1
templates/en-us/create_event.alpine.html
··· 768 768 769 769 init() { 770 770 // Load existing location data from server if present 771 - {% if location_form.location_country %} 771 + {% if event_locations %} 772 + {% for loc in event_locations %} 773 + this.formData.locations.push({ 774 + country: {{ loc.country | tojson }}, 775 + postal_code: {{ loc.postal_code | tojson }}, 776 + region: {{ loc.region | tojson }}, 777 + locality: {{ loc.locality | tojson }}, 778 + street: {{ loc.street | tojson }}, 779 + name: {{ loc.name | tojson }}, 780 + }); 781 + {% endfor %} 782 + {% elif location_form.location_country %} 783 + // Fallback to old single-location format 772 784 this.formData.locations.push({ 773 785 country: {{ location_form.location_country | tojson }}, 774 786 postal_code: {% if location_form.location_postal_code %}{{ location_form.location_postal_code | tojson }}{% else %}null{% endif %}, ··· 777 789 street: {% if location_form.location_street %}{{ location_form.location_street | tojson }}{% else %}null{% endif %}, 778 790 name: {% if location_form.location_name %}{{ location_form.location_name | tojson }}{% else %}null{% endif %}, 779 791 }); 792 + {% endif %} 793 + 794 + // Load existing geo locations from server if present 795 + {% if event_geo_locations %} 796 + {% for geo in event_geo_locations %} 797 + this.formData.geo_locations.push({ 798 + latitude: {{ geo.latitude | tojson }}, 799 + longitude: {{ geo.longitude | tojson }}, 800 + name: {{ geo.name | tojson }}, 801 + }); 802 + {% endfor %} 780 803 {% endif %} 781 804 782 805 // Load existing links from server if present
+10
templates/en-us/event_list.incl.html
··· 158 158 <p>{% autoescape false %}{{ event.description_short }}{% endautoescape %}</p> 159 159 </div> 160 160 161 + {% if user_tags and event.description_tags %} 162 + <div class="tags"> 163 + {% for tag in event.description_tags %} 164 + {% if tag | lower in user_tags %} 165 + <span class="tag is-info is-small">{{ tag }}</span> 166 + {% endif %} 167 + {% endfor %} 168 + </div> 169 + {% endif %} 170 + 161 171 </div> 162 172 </article> 163 173
+2 -2
templates/en-us/index.common.html
··· 48 48 <span class="icon"> 49 49 <i class="fas fa-check-circle"></i> 50 50 </span> 51 - <span>A network of {{ event_count }} events and {{ rsvp_count }} RSVPs</span> 51 + <span>A network of {{ event_count }} events and {{ rsvp_count }} RSVPs with {{ lfg_identities_count }} people <a href="/lfg">LFG</a> accross {{ lfg_locations_count }} locations.</span> 52 52 </p> 53 53 </div> 54 54 </div> ··· 278 278 279 279 // Default center (world view) if geolocation fails 280 280 const DEFAULT_CENTER = [39.8283, -98.5795]; // Center of USA 281 - const DEFAULT_ZOOM = 9; 281 + const DEFAULT_ZOOM = 10; 282 282 283 283 // Color scale for heatmap (low to high) 284 284 const colors = ['#ffffcc', '#ffeda0', '#fed976', '#feb24c', '#fd8d3c', '#fc4e2a', '#e31a1c', '#bd0026', '#800026'];
+4
templates/en-us/lfg_form.bare.html
··· 1 + {% extends "en-us/bare.html" %} 2 + {% block content %} 3 + {% include 'en-us/lfg_form.common.html' %} 4 + {% endblock %}
+329
templates/en-us/lfg_form.common.html
··· 1 + <section class="section"> 2 + <div class="container"> 3 + <h1 class="title"> 4 + <span class="icon"><i class="fas fa-users"></i></span> 5 + Looking For Group 6 + </h1> 7 + <p class="subtitle">Find activity partners in your area</p> 8 + 9 + <div x-data="lfgForm()" class="box"> 10 + <form @submit.prevent="submitForm()"> 11 + 12 + <!-- Location Section --> 13 + <div class="field"> 14 + <label class="label"> 15 + <span class="icon"><i class="fas fa-map-marker-alt"></i></span> 16 + Select Your Location 17 + </label> 18 + <p class="help mb-3">Click on the map to select your general area. Click again on the highlighted area to unselect.</p> 19 + 20 + <div id="lfg-map" style="height: 300px; border-radius: 6px; border: 1px solid #dbdbdb;"></div> 21 + 22 + <p class="help mt-2" x-show="h3Index" x-cloak> 23 + <span class="icon has-text-success"><i class="fas fa-check"></i></span> 24 + Location selected <button type="button" class="button is-small is-light ml-2" @click="clearLocation()">Clear</button> 25 + </p> 26 + <p class="help is-danger" x-show="errors.location" x-text="errors.location" x-cloak></p> 27 + </div> 28 + 29 + <!-- Tags Section --> 30 + <div class="field"> 31 + <label class="label"> 32 + <span class="icon"><i class="fas fa-tags"></i></span> 33 + Interests & Tags 34 + </label> 35 + <p class="help mb-2">Add 1-10 tags describing what you're looking for</p> 36 + 37 + <div class="control"> 38 + <input 39 + type="text" 40 + class="input" 41 + placeholder="Type a tag and press Enter..." 42 + x-model="tagInput" 43 + @keydown.enter.prevent="addTag()" 44 + @keydown.comma.prevent="addTag()" 45 + autocomplete="off" 46 + > 47 + </div> 48 + 49 + <!-- Selected Tags --> 50 + <div class="tags mt-2"> 51 + <template x-for="(tag, index) in tags" :key="index"> 52 + <span class="tag is-info is-medium"> 53 + <span x-text="tag"></span> 54 + <button type="button" class="delete is-small" @click="removeTag(index)"></button> 55 + </span> 56 + </template> 57 + </div> 58 + 59 + <p class="help is-danger" x-show="errors.tags" x-text="errors.tags" x-cloak></p> 60 + 61 + <!-- Popular Tags --> 62 + {% if popular_tags %} 63 + <div class="mt-3"> 64 + <p class="help mb-1">Popular tags:</p> 65 + <div class="tags"> 66 + {% for tag in popular_tags %} 67 + <button type="button" class="tag is-light" @click="addTagFromSuggestion('{{ tag[0] }}')"> 68 + {{ tag[0] }} <span class="has-text-grey-light ml-1">({{ tag[1] }})</span> 69 + </button> 70 + {% endfor %} 71 + </div> 72 + </div> 73 + {% endif %} 74 + </div> 75 + 76 + <!-- Duration Section --> 77 + <div class="field"> 78 + <label class="label"> 79 + <span class="icon"><i class="fas fa-clock"></i></span> 80 + Duration 81 + </label> 82 + <p class="help mb-2">How long should your LFG be active?</p> 83 + 84 + <div class="control"> 85 + <div class="select is-fullwidth"> 86 + <select x-model="durationHours"> 87 + <option value="6">6 hours</option> 88 + <option value="12">12 hours</option> 89 + <option value="24">24 hours</option> 90 + <option value="48" selected>48 hours (2 days)</option> 91 + <option value="72">72 hours (3 days)</option> 92 + </select> 93 + </div> 94 + </div> 95 + 96 + <p class="help is-danger" x-show="errors.duration" x-text="errors.duration" x-cloak></p> 97 + </div> 98 + 99 + <!-- General Error --> 100 + <div class="notification is-danger" x-show="errors.general" x-cloak> 101 + <span x-text="errors.general"></span> 102 + </div> 103 + 104 + <!-- Submit Section --> 105 + <div class="field is-grouped mt-5"> 106 + <div class="control"> 107 + <button type="submit" class="button is-primary is-medium" :disabled="!canSubmit() || submitting" :class="{ 'is-loading': submitting }"> 108 + <span class="icon"><i class="fas fa-broadcast-tower"></i></span> 109 + <span>Start Looking</span> 110 + </button> 111 + </div> 112 + <div class="control"> 113 + <a href="/" class="button is-light is-medium">Cancel</a> 114 + </div> 115 + </div> 116 + </form> 117 + </div> 118 + </div> 119 + </section> 120 + 121 + <script> 122 + function lfgForm() { 123 + // H3 resolution 7 gives ~5 km² area (roughly 1.2km edge) 124 + const H3_RESOLUTION = 7; 125 + 126 + return { 127 + latitude: '', 128 + longitude: '', 129 + h3Index: '', 130 + tags: [], 131 + tagInput: '', 132 + durationHours: '{{ default_duration }}', 133 + map: null, 134 + hexLayer: null, 135 + submitting: false, 136 + errors: { 137 + location: null, 138 + tags: null, 139 + duration: null, 140 + general: null 141 + }, 142 + 143 + init() { 144 + this.$nextTick(() => { 145 + this.initMap(); 146 + }); 147 + }, 148 + 149 + initMap() { 150 + // Initialize map centered on default location 151 + const defaultLat = 40.7128; 152 + const defaultLon = -74.0060; 153 + 154 + this.map = L.map('lfg-map').setView([defaultLat, defaultLon], 12); 155 + 156 + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { 157 + attribution: '&copy; OpenStreetMap contributors' 158 + }).addTo(this.map); 159 + 160 + // Handle map clicks 161 + this.map.on('click', (e) => { 162 + this.handleMapClick(e.latlng.lat, e.latlng.lng); 163 + }); 164 + 165 + // Try to get user's location 166 + if (navigator.geolocation) { 167 + navigator.geolocation.getCurrentPosition((position) => { 168 + const lat = position.coords.latitude; 169 + const lon = position.coords.longitude; 170 + this.map.setView([lat, lon], 12); 171 + }, () => { 172 + // Geolocation denied or failed, use default 173 + }); 174 + } 175 + }, 176 + 177 + handleMapClick(lat, lon) { 178 + // Get H3 cell at resolution 5 179 + const clickedH3Index = h3.latLngToCell(lat, lon, H3_RESOLUTION); 180 + 181 + // Check if clicking on already selected cell - toggle off 182 + if (this.h3Index === clickedH3Index) { 183 + this.clearLocation(); 184 + return; 185 + } 186 + 187 + // Select new location 188 + this.selectLocation(clickedH3Index); 189 + this.errors.location = null; 190 + }, 191 + 192 + selectLocation(h3Index) { 193 + // Remove existing hex layer 194 + if (this.hexLayer) { 195 + this.map.removeLayer(this.hexLayer); 196 + } 197 + 198 + this.h3Index = h3Index; 199 + 200 + // Get boundary for drawing - h3.cellToBoundary returns [lat, lng] pairs 201 + const boundary = h3.cellToBoundary(h3Index); 202 + const latLngs = boundary.map(coord => [coord[0], coord[1]]); 203 + 204 + // Draw the hex with tooltip 205 + this.hexLayer = L.polygon(latLngs, { 206 + color: '#00d1b2', 207 + fillColor: '#00d1b2', 208 + fillOpacity: 0.3, 209 + weight: 3 210 + }).addTo(this.map); 211 + 212 + // Add tooltip to indicate it can be clicked to unselect 213 + this.hexLayer.bindTooltip('Click to unselect', { 214 + permanent: false, 215 + direction: 'center' 216 + }); 217 + 218 + // Handle click on the polygon to unselect 219 + this.hexLayer.on('click', () => { 220 + this.clearLocation(); 221 + }); 222 + 223 + // Get center coordinates for the API 224 + const center = h3.cellToLatLng(h3Index); 225 + this.latitude = center[0].toString(); 226 + this.longitude = center[1].toString(); 227 + }, 228 + 229 + clearLocation() { 230 + if (this.hexLayer) { 231 + this.map.removeLayer(this.hexLayer); 232 + this.hexLayer = null; 233 + } 234 + this.h3Index = ''; 235 + this.latitude = ''; 236 + this.longitude = ''; 237 + }, 238 + 239 + addTag() { 240 + const tag = this.tagInput.trim().replace(/[^a-zA-Z0-9-]/g, ''); 241 + // Check for duplicates case-insensitively 242 + const tagLower = tag.toLowerCase(); 243 + const isDuplicate = this.tags.some(t => t.toLowerCase() === tagLower); 244 + if (tag && !isDuplicate && this.tags.length < 10) { 245 + this.tags.push(tag); 246 + this.errors.tags = null; 247 + } 248 + this.tagInput = ''; 249 + }, 250 + 251 + addTagFromSuggestion(tag) { 252 + // Check for duplicates case-insensitively 253 + const tagLower = tag.toLowerCase(); 254 + const isDuplicate = this.tags.some(t => t.toLowerCase() === tagLower); 255 + if (!isDuplicate && this.tags.length < 10) { 256 + this.tags.push(tag); 257 + this.errors.tags = null; 258 + } 259 + }, 260 + 261 + removeTag(index) { 262 + this.tags.splice(index, 1); 263 + }, 264 + 265 + canSubmit() { 266 + return this.h3Index && this.tags.length >= 1; 267 + }, 268 + 269 + clearErrors() { 270 + this.errors = { 271 + location: null, 272 + tags: null, 273 + duration: null, 274 + general: null 275 + }; 276 + }, 277 + 278 + async submitForm() { 279 + if (!this.canSubmit() || this.submitting) return; 280 + 281 + this.clearErrors(); 282 + this.submitting = true; 283 + 284 + const payload = { 285 + latitude: parseFloat(this.latitude), 286 + longitude: parseFloat(this.longitude), 287 + tags: this.tags, 288 + duration_hours: parseInt(this.durationHours, 10) 289 + }; 290 + 291 + try { 292 + const response = await fetch('/lfg', { 293 + method: 'POST', 294 + headers: { 295 + 'Content-Type': 'application/json', 296 + }, 297 + body: JSON.stringify(payload) 298 + }); 299 + 300 + if (response.ok) { 301 + // Success - redirect to LFG page which will show matches view 302 + window.location.href = '/lfg'; 303 + } else { 304 + const data = await response.json(); 305 + if (data.error) { 306 + // Map error codes to fields 307 + if (data.error.includes('location') || data.error.includes('coordinate')) { 308 + this.errors.location = data.message || 'Invalid location'; 309 + } else if (data.error.includes('tag')) { 310 + this.errors.tags = data.message || 'Invalid tags'; 311 + } else if (data.error.includes('duration')) { 312 + this.errors.duration = data.message || 'Invalid duration'; 313 + } else { 314 + this.errors.general = data.message || 'An error occurred'; 315 + } 316 + } else { 317 + this.errors.general = 'An error occurred. Please try again.'; 318 + } 319 + } 320 + } catch (err) { 321 + console.error('LFG submission error:', err); 322 + this.errors.general = 'Network error. Please check your connection and try again.'; 323 + } finally { 324 + this.submitting = false; 325 + } 326 + } 327 + }; 328 + } 329 + </script>
+15
templates/en-us/lfg_form.html
··· 1 + {% extends "en-us/base.html" %} 2 + {% block title %}Looking For Group - Smoke Signal{% endblock %} 3 + {% block head %} 4 + <meta name="description" content="Find activity partners in your area with Looking For Group"> 5 + <meta property="og:title" content="Looking For Group"> 6 + <meta property="og:description" content="Find activity partners in your area with Looking For Group"> 7 + <meta property="og:site_name" content="Smoke Signal" /> 8 + <meta property="og:type" content="website" /> 9 + <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> 10 + <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> 11 + <script src="https://unpkg.com/h3-js@4"></script> 12 + {% endblock %} 13 + {% block content %} 14 + {% include 'en-us/lfg_form.common.html' %} 15 + {% endblock %}
+4
templates/en-us/lfg_matches.bare.html
··· 1 + {% extends "en-us/bare.html" %} 2 + {% block content %} 3 + {% include 'en-us/lfg_matches.common.html' %} 4 + {% endblock %}
+194
templates/en-us/lfg_matches.common.html
··· 1 + <section class="section"> 2 + <div class="container"> 3 + <div class="mb-4"> 4 + <div class="level mb-2"> 5 + <div class="level-left"> 6 + <div class="level-item"> 7 + <p class="title is-5 mb-0"> 8 + <span class="icon has-text-success"><i class="fas fa-broadcast-tower"></i></span> 9 + Active 10 + </p> 11 + </div> 12 + </div> 13 + <div class="level-right"> 14 + <div class="level-item"> 15 + <form method="POST" action="/lfg/deactivate"> 16 + <button type="submit" class="button is-danger is-outlined"> 17 + <span class="icon"><i class="fas fa-stop"></i></span> 18 + <span>Deactivate</span> 19 + </button> 20 + </form> 21 + </div> 22 + </div> 23 + </div> 24 + 25 + {% if lfg_record %} 26 + <div> 27 + <p class="mb-2"> 28 + {% for tag in lfg_record.tags %} 29 + <span class="tag is-info">{{ tag }}</span> 30 + {% endfor %} 31 + {% if h3_cell %} 32 + <span class="tag is-light"> 33 + <span class="icon is-small"><i class="fas fa-map-marker-alt"></i></span> 34 + <span>{{ h3_cell }}</span> 35 + </span> 36 + {% endif %} 37 + </p> 38 + <p class="has-text-grey is-size-7"> 39 + Expires at <time datetime="{{ lfg_record.endsAt }}">{{ lfg_record.endsAt }}</time> 40 + </p> 41 + </div> 42 + {% endif %} 43 + </div> 44 + 45 + <!-- Heatmap --> 46 + <div class="mb-4"> 47 + <h2 class="subtitle"> 48 + <span class="icon"><i class="fas fa-map"></i></span> 49 + Activity 50 + </h2> 51 + <div id="lfg-heatmap" style="height: 400px; border-radius: 6px;"></div> 52 + </div> 53 + 54 + <!-- Two Column Layout --> 55 + <div class="columns"> 56 + <!-- Events Column --> 57 + <div class="column is-half"> 58 + <div> 59 + <h2 class="subtitle"> 60 + <span class="icon"><i class="fas fa-calendar-alt"></i></span> 61 + Events 62 + <span class="tag is-light">{{ events | length }}</span> 63 + </h2> 64 + 65 + {% if events | length > 0 %} 66 + {% set base = "" %} 67 + {% include 'en-us/event_list.incl.html' %} 68 + {% else %} 69 + <p class="has-text-grey"> 70 + No matching events found nearby. Check back later or adjust your tags. 71 + </p> 72 + {% endif %} 73 + </div> 74 + </div> 75 + 76 + <!-- People Column --> 77 + <div class="column is-half"> 78 + <div> 79 + <h2 class="subtitle"> 80 + <span class="icon"><i class="fas fa-user-friends"></i></span> 81 + People 82 + <span class="tag is-light">{{ matching_profiles | length }}</span> 83 + </h2> 84 + 85 + {% if matching_profiles | length > 0 %} 86 + <div class="content"> 87 + {% for profile in matching_profiles %} 88 + <article class="media"> 89 + <div class="media-content"> 90 + <p> 91 + <span class="has-text-weight-bold"> 92 + <span class="icon is-small"><i class="fas fa-user"></i></span> 93 + {% if profile.display_name %} 94 + {{ profile.display_name }} 95 + {% elif profile.handle %} 96 + @{{ profile.handle }} 97 + {% else %} 98 + {{ profile.did }} 99 + {% endif %} 100 + </span> 101 + {% if profile.display_name and profile.handle %} 102 + <br> 103 + <small class="has-text-grey">@{{ profile.handle }}</small> 104 + {% endif %} 105 + <br> 106 + <small> 107 + {% for tag in profile.tags %} 108 + {% if tag | lower in user_tags %} 109 + <span class="tag is-small is-info">{{ tag }}</span> 110 + {% else %} 111 + <span class="tag is-small is-light">{{ tag }}</span> 112 + {% endif %} 113 + {% endfor %} 114 + </small> 115 + </p> 116 + </div> 117 + </article> 118 + {% endfor %} 119 + </div> 120 + {% else %} 121 + <p class="has-text-grey"> 122 + No other people looking in your area yet. Share your profile to connect! 123 + </p> 124 + {% endif %} 125 + </div> 126 + </div> 127 + </div> 128 + </div> 129 + </section> 130 + 131 + <script> 132 + document.addEventListener('DOMContentLoaded', function() { 133 + // Initialize map centered on user's LFG location 134 + const lat = {{ latitude | default(40.7128) }}; 135 + const lon = {{ longitude | default(-74.0060) }}; 136 + 137 + const map = L.map('lfg-heatmap').setView([lat, lon], 12); 138 + 139 + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { 140 + attribution: '&copy; OpenStreetMap contributors' 141 + }).addTo(map); 142 + 143 + // Combine event and profile buckets into a single heatmap 144 + const eventBuckets = {{ event_buckets | tojson | safe }} || []; 145 + const profileBuckets = {{ profile_buckets | tojson | safe }} || []; 146 + 147 + // Merge buckets by H3 key 148 + const combinedBuckets = new Map(); 149 + eventBuckets.forEach(bucket => { 150 + const existing = combinedBuckets.get(bucket.key) || { events: 0, people: 0 }; 151 + existing.events = bucket.count; 152 + combinedBuckets.set(bucket.key, existing); 153 + }); 154 + profileBuckets.forEach(bucket => { 155 + const existing = combinedBuckets.get(bucket.key) || { events: 0, people: 0 }; 156 + existing.people = bucket.count; 157 + combinedBuckets.set(bucket.key, existing); 158 + }); 159 + 160 + // Calculate max combined count for intensity scaling 161 + let maxCount = 0; 162 + combinedBuckets.forEach(value => { 163 + const total = value.events + value.people; 164 + if (total > maxCount) maxCount = total; 165 + }); 166 + 167 + // Draw combined heatmap hexes 168 + combinedBuckets.forEach((value, key) => { 169 + try { 170 + const boundary = h3.cellToBoundary(key); 171 + const latLngs = boundary.map(coord => [coord[0], coord[1]]); 172 + const total = value.events + value.people; 173 + const intensity = Math.min(total / Math.max(maxCount, 1), 1); 174 + const opacity = 0.2 + (intensity * 0.5); 175 + 176 + // Build tooltip text 177 + const parts = []; 178 + if (value.events > 0) parts.push(`${value.events} event${value.events !== 1 ? 's' : ''}`); 179 + if (value.people > 0) parts.push(`${value.people} ${value.people !== 1 ? 'people' : 'person'}`); 180 + const tooltipText = parts.join(', '); 181 + 182 + L.polygon(latLngs, { 183 + color: '#3273dc', 184 + fillColor: '#3273dc', 185 + fillOpacity: opacity, 186 + weight: 1 187 + }).addTo(map).bindTooltip(tooltipText, { direction: 'center' }); 188 + } catch (e) { 189 + console.warn('Invalid H3 cell:', key); 190 + } 191 + }); 192 + 193 + }); 194 + </script>
+15
templates/en-us/lfg_matches.html
··· 1 + {% extends "en-us/base.html" %} 2 + {% block title %}Looking For Group - Smoke Signal{% endblock %} 3 + {% block head %} 4 + <meta name="description" content="Find activity partners in your area with Looking For Group"> 5 + <meta property="og:title" content="Looking For Group"> 6 + <meta property="og:description" content="Find activity partners in your area with Looking For Group"> 7 + <meta property="og:site_name" content="Smoke Signal" /> 8 + <meta property="og:type" content="website" /> 9 + <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" /> 10 + <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> 11 + <script src="https://unpkg.com/h3-js@4"></script> 12 + {% endblock %} 13 + {% block content %} 14 + {% include 'en-us/lfg_matches.common.html' %} 15 + {% endblock %}
+6
templates/en-us/nav.html
··· 28 28 </span> 29 29 <span>Search</span> 30 30 </a> 31 + <a class="navbar-item" href="/lfg" hx-boost="true"> 32 + <span class="icon"> 33 + <i class="fas fa-users"></i> 34 + </span> 35 + <span>LFG</span> 36 + </a> 31 37 <a class="navbar-item" href="https://discourse.smokesignal.events/c/documentation/7" target="_blank"> 32 38 Help 33 39 </a>