···11use anyhow::Result;
22use atproto_client::client::get_dpop_json_with_headers;
33+use deadpool_redis::redis::AsyncCommands;
34use http::HeaderMap;
45use reqwest::Client;
66+use sha2::{Digest, Sha256};
5768use crate::atproto::auth::create_dpop_auth_from_aip_session;
79use crate::config::OAuthBackendConfig;
···911use crate::http::errors::LoginError;
1012use crate::http::errors::web_error::WebError;
1113use crate::http::middleware_auth::Auth;
1414+1515+/// TTL for AIP session ready cache entries (5 minutes).
1616+const AIP_SESSION_READY_CACHE_TTL_SECS: i64 = 300;
12171318/// Result of checking if an AIP session is ready for AT Protocol operations.
1419pub(crate) enum AipSessionStatus {
···8287 }
8388}
84899090+/// Generate a cache key for AIP session ready status.
9191+///
9292+/// Uses a SHA-256 hash of the access token to avoid storing raw tokens in Redis.
9393+fn aip_session_cache_key(access_token: &str) -> String {
9494+ let mut hasher = Sha256::new();
9595+ hasher.update(access_token.as_bytes());
9696+ let hash = hasher.finalize();
9797+ format!("aip_session_ready:{:x}", hash)
9898+}
9999+100100+/// Check Redis cache for AIP session ready status.
101101+///
102102+/// Returns `Some(true)` if cached as ready, `Some(false)` if cached as stale,
103103+/// or `None` on cache miss or error.
104104+async fn get_cached_aip_session_status(
105105+ web_context: &WebContext,
106106+ access_token: &str,
107107+) -> Option<bool> {
108108+ let cache_key = aip_session_cache_key(access_token);
109109+110110+ let mut conn = match web_context.cache_pool.get().await {
111111+ Ok(conn) => conn,
112112+ Err(e) => {
113113+ tracing::debug!(?e, "Failed to get Redis connection for AIP session cache");
114114+ return None;
115115+ }
116116+ };
117117+118118+ match conn.get::<_, Option<String>>(&cache_key).await {
119119+ Ok(Some(value)) => {
120120+ tracing::debug!(cache_key = %cache_key, value = %value, "AIP session cache hit");
121121+ Some(value == "ready")
122122+ }
123123+ Ok(None) => {
124124+ tracing::debug!(cache_key = %cache_key, "AIP session cache miss");
125125+ None
126126+ }
127127+ Err(e) => {
128128+ tracing::debug!(?e, "Redis error reading AIP session cache");
129129+ None
130130+ }
131131+ }
132132+}
133133+134134+/// Cache AIP session ready status in Redis with TTL.
135135+async fn cache_aip_session_status(web_context: &WebContext, access_token: &str, is_ready: bool) {
136136+ let cache_key = aip_session_cache_key(access_token);
137137+ let value = if is_ready { "ready" } else { "stale" };
138138+139139+ let mut conn = match web_context.cache_pool.get().await {
140140+ Ok(conn) => conn,
141141+ Err(e) => {
142142+ tracing::debug!(?e, "Failed to get Redis connection for AIP session cache write");
143143+ return;
144144+ }
145145+ };
146146+147147+ if let Err(e) = conn
148148+ .set_ex::<_, _, ()>(&cache_key, value, AIP_SESSION_READY_CACHE_TTL_SECS as u64)
149149+ .await
150150+ {
151151+ tracing::debug!(?e, "Failed to cache AIP session status");
152152+ } else {
153153+ tracing::debug!(
154154+ cache_key = %cache_key,
155155+ value = %value,
156156+ ttl_secs = AIP_SESSION_READY_CACHE_TTL_SECS,
157157+ "Cached AIP session status"
158158+ );
159159+ }
160160+}
161161+85162/// Check if the current AIP session is ready for AT Protocol operations.
86163///
87164/// This calls the AIP ready endpoint to validate the access token.
165165+/// Results are cached in Redis for 5 minutes to avoid repeated validation.
88166/// For PDS sessions, this is a no-op (returns NotAip).
89167pub(crate) async fn require_valid_aip_session(
90168 web_context: &WebContext,
···101179 return Ok(AipSessionStatus::Stale);
102180 }
103181182182+ // Check Redis cache first
183183+ if let Some(cached_ready) =
184184+ get_cached_aip_session_status(web_context, access_token).await
185185+ {
186186+ return if cached_ready {
187187+ Ok(AipSessionStatus::Ready)
188188+ } else {
189189+ Ok(AipSessionStatus::Stale)
190190+ };
191191+ }
192192+104193 // Get AIP hostname from config
105194 let aip_hostname = match &web_context.config.oauth_backend {
106195 OAuthBackendConfig::AIP { hostname, .. } => hostname,
···119208 tracing::warn!(?e, "AIP ready check failed");
120209 WebError::InternalError
121210 })?;
211211+212212+ // Cache the result
213213+ cache_aip_session_status(web_context, access_token, is_ready).await;
122214123215 if is_ready {
124216 Ok(AipSessionStatus::Ready)
+79
src/http/errors/lfg_error.rs
···11+use thiserror::Error;
22+33+/// Represents errors that can occur during LFG (Looking For Group) operations.
44+///
55+/// These errors are typically triggered during validation of user-submitted
66+/// LFG creation forms or during LFG record operations.
77+#[derive(Debug, Error)]
88+pub(crate) enum LfgError {
99+ /// Error when the location is not provided.
1010+ ///
1111+ /// This error occurs when a user attempts to create an LFG record without
1212+ /// selecting a location on the map.
1313+ #[error("error-smokesignal-lfg-1 Location not set")]
1414+ LocationNotSet,
1515+1616+ /// Error when the coordinates are invalid.
1717+ ///
1818+ /// This error occurs when the provided latitude or longitude
1919+ /// values are not valid numbers or are out of range.
2020+ #[error("error-smokesignal-lfg-2 Invalid coordinates: {0}")]
2121+ InvalidCoordinates(String),
2222+2323+ /// Error when no tags are provided.
2424+ ///
2525+ /// This error occurs when a user attempts to create an LFG record without
2626+ /// specifying at least one interest tag.
2727+ #[error("error-smokesignal-lfg-3 Tags required (at least one)")]
2828+ TagsRequired,
2929+3030+ /// Error when too many tags are provided.
3131+ ///
3232+ /// This error occurs when a user attempts to create an LFG record with
3333+ /// more than the maximum allowed number of tags (10).
3434+ #[error("error-smokesignal-lfg-4 Too many tags (maximum 10)")]
3535+ TooManyTags,
3636+3737+ /// Error when an invalid duration is specified.
3838+ ///
3939+ /// This error occurs when the provided duration value is not one of
4040+ /// the allowed options (6, 12, 24, 48, or 72 hours).
4141+ #[error("error-smokesignal-lfg-5 Invalid duration")]
4242+ InvalidDuration,
4343+4444+ /// Error when the PDS record creation fails.
4545+ ///
4646+ /// This error occurs when the AT Protocol server returns an error
4747+ /// during LFG record creation.
4848+ #[error("error-smokesignal-lfg-6 Failed to create PDS record: {message}")]
4949+ PdsRecordCreationFailed { message: String },
5050+5151+ /// Error when no active LFG record is found.
5252+ ///
5353+ /// This error occurs when attempting to perform operations that
5454+ /// require an active LFG record (e.g., deactivation, viewing matches).
5555+ #[error("error-smokesignal-lfg-7 No active LFG record found")]
5656+ NoActiveRecord,
5757+5858+ /// Error when user already has an active LFG record.
5959+ ///
6060+ /// This error occurs when a user attempts to create a new LFG record
6161+ /// while they already have an active one. Users must deactivate their
6262+ /// existing record before creating a new one.
6363+ #[error("error-smokesignal-lfg-8 Active LFG record already exists")]
6464+ ActiveRecordExists,
6565+6666+ /// Error when deactivation fails.
6767+ ///
6868+ /// This error occurs when the attempt to deactivate an LFG record
6969+ /// fails due to a server or network error.
7070+ #[error("error-smokesignal-lfg-9 Failed to deactivate LFG record: {message}")]
7171+ DeactivationFailed { message: String },
7272+7373+ /// Error when a tag is invalid.
7474+ ///
7575+ /// This error occurs when a provided tag is empty or exceeds
7676+ /// the maximum allowed length.
7777+ #[error("error-smokesignal-lfg-10 Invalid tag: {0}")]
7878+ InvalidTag(String),
7979+}
+2
src/http/errors/mod.rs
···88pub mod delete_event_errors;
99pub mod event_view_errors;
1010pub mod import_error;
1111+pub mod lfg_error;
1112pub mod login_error;
1213pub mod profile_import_error;
1314pub mod middleware_errors;
···2122pub(crate) use delete_event_errors::DeleteEventError;
2223pub(crate) use event_view_errors::EventViewError;
2324pub(crate) use import_error::ImportError;
2525+pub(crate) use lfg_error::LfgError;
2426pub(crate) use login_error::LoginError;
2527pub(crate) use profile_import_error::ProfileImportError;
2628pub(crate) use middleware_errors::WebSessionError;
+8
src/http/errors/web_error.rs
···1818use super::create_event_errors::CreateEventError;
1919use super::event_view_errors::EventViewError;
2020use super::import_error::ImportError;
2121+use super::lfg_error::LfgError;
2122use super::login_error::LoginError;
2223use super::middleware_errors::MiddlewareAuthError;
2324use super::url_error::UrlError;
···158159 /// such as avatar/banner uploads or AT Protocol record operations.
159160 #[error(transparent)]
160161 BlobError(#[from] BlobError),
162162+163163+ /// Looking For Group (LFG) errors.
164164+ ///
165165+ /// This error occurs when there are issues with LFG operations,
166166+ /// such as creating, viewing, or deactivating LFG records.
167167+ #[error(transparent)]
168168+ LfgError(#[from] LfgError),
161169162170 /// The AIP session has expired and the user must re-authenticate.
163171 ///
+233
src/http/h3_utils.rs
···11+//! H3 geospatial indexing utilities.
22+//!
33+//! This module provides helper functions for working with H3 hexagonal
44+//! hierarchical spatial indexes.
55+66+use h3o::{CellIndex, LatLng, Resolution};
77+88+/// Default H3 resolution for LFG location selection (precision 6 = ~36km edge)
99+pub const DEFAULT_RESOLUTION: Resolution = Resolution::Six;
1010+1111+/// Convert lat/lon to H3 cell index at the default resolution (6).
1212+///
1313+/// # Arguments
1414+/// * `lat` - Latitude in degrees (-90 to 90)
1515+/// * `lon` - Longitude in degrees (-180 to 180)
1616+///
1717+/// # Returns
1818+/// The H3 cell index as a string, or an error message.
1919+pub fn lat_lon_to_h3(lat: f64, lon: f64) -> Result<String, String> {
2020+ lat_lon_to_h3_with_resolution(lat, lon, DEFAULT_RESOLUTION)
2121+}
2222+2323+/// Convert lat/lon to H3 cell index at a specific resolution.
2424+///
2525+/// # Arguments
2626+/// * `lat` - Latitude in degrees (-90 to 90)
2727+/// * `lon` - Longitude in degrees (-180 to 180)
2828+/// * `resolution` - H3 resolution (0-15)
2929+///
3030+/// # Returns
3131+/// The H3 cell index as a string, or an error message.
3232+pub fn lat_lon_to_h3_with_resolution(
3333+ lat: f64,
3434+ lon: f64,
3535+ resolution: Resolution,
3636+) -> Result<String, String> {
3737+ let coord =
3838+ LatLng::new(lat, lon).map_err(|e| format!("Invalid coordinates: {}", e))?;
3939+ let cell = coord.to_cell(resolution);
4040+ Ok(cell.to_string())
4141+}
4242+4343+/// Validate an H3 index string and parse it into a CellIndex.
4444+///
4545+/// # Arguments
4646+/// * `h3_str` - The H3 index as a hexadecimal string
4747+///
4848+/// # Returns
4949+/// The parsed CellIndex, or an error message.
5050+pub fn validate_h3_index(h3_str: &str) -> Result<CellIndex, String> {
5151+ h3_str
5252+ .parse::<CellIndex>()
5353+ .map_err(|e| format!("Invalid H3 index: {}", e))
5454+}
5555+5656+/// Get the center coordinates of an H3 cell.
5757+///
5858+/// # Arguments
5959+/// * `h3_str` - The H3 index as a hexadecimal string
6060+///
6161+/// # Returns
6262+/// A tuple of (latitude, longitude) for the cell center, or an error message.
6363+pub fn h3_to_lat_lon(h3_str: &str) -> Result<(f64, f64), String> {
6464+ let cell = validate_h3_index(h3_str)?;
6565+ let center = LatLng::from(cell);
6666+ Ok((center.lat(), center.lng()))
6767+}
6868+6969+/// Get neighboring H3 cells within k rings.
7070+///
7171+/// # Arguments
7272+/// * `h3_str` - The H3 index as a hexadecimal string
7373+/// * `k` - The number of rings to include (0 = just the cell, 1 = cell + immediate neighbors)
7474+///
7575+/// # Returns
7676+/// A vector of H3 cell indexes as strings, or an error message.
7777+pub fn h3_neighbors(h3_str: &str, k: u32) -> Result<Vec<String>, String> {
7878+ let cell = validate_h3_index(h3_str)?;
7979+ let neighbors: Vec<String> = cell
8080+ .grid_disk::<Vec<_>>(k)
8181+ .into_iter()
8282+ .map(|c| c.to_string())
8383+ .collect();
8484+ Ok(neighbors)
8585+}
8686+8787+/// Get the boundary vertices of an H3 cell for map display.
8888+///
8989+/// # Arguments
9090+/// * `h3_str` - The H3 index as a hexadecimal string
9191+///
9292+/// # Returns
9393+/// A vector of (latitude, longitude) tuples forming the cell boundary, or an error message.
9494+pub fn h3_boundary(h3_str: &str) -> Result<Vec<(f64, f64)>, String> {
9595+ let cell = validate_h3_index(h3_str)?;
9696+ let boundary = cell.boundary();
9797+ Ok(boundary.iter().map(|v| (v.lat(), v.lng())).collect())
9898+}
9999+100100+/// Get the resolution of an H3 cell.
101101+///
102102+/// # Arguments
103103+/// * `h3_str` - The H3 index as a hexadecimal string
104104+///
105105+/// # Returns
106106+/// The resolution (0-15), or an error message.
107107+pub fn h3_resolution(h3_str: &str) -> Result<u8, String> {
108108+ let cell = validate_h3_index(h3_str)?;
109109+ Ok(cell.resolution() as u8)
110110+}
111111+112112+/// Calculate the approximate area of an H3 cell in square kilometers.
113113+///
114114+/// # Arguments
115115+/// * `h3_str` - The H3 index as a hexadecimal string
116116+///
117117+/// # Returns
118118+/// The area in square kilometers, or an error message.
119119+pub fn h3_area_km2(h3_str: &str) -> Result<f64, String> {
120120+ let cell = validate_h3_index(h3_str)?;
121121+ Ok(cell.area_km2())
122122+}
123123+124124+#[cfg(test)]
125125+mod tests {
126126+ use super::*;
127127+128128+ #[test]
129129+ fn test_lat_lon_to_h3() {
130130+ // New York City coordinates
131131+ let result = lat_lon_to_h3(40.7128, -74.0060);
132132+ assert!(result.is_ok());
133133+ let h3_index = result.unwrap();
134134+ assert!(!h3_index.is_empty());
135135+ // H3 indexes are 15-character hex strings
136136+ assert_eq!(h3_index.len(), 15);
137137+ }
138138+139139+ #[test]
140140+ fn test_lat_lon_to_h3_invalid() {
141141+ // Invalid latitude
142142+ let result = lat_lon_to_h3(100.0, 0.0);
143143+ assert!(result.is_err());
144144+145145+ // Invalid longitude
146146+ let result = lat_lon_to_h3(0.0, 200.0);
147147+ assert!(result.is_err());
148148+ }
149149+150150+ #[test]
151151+ fn test_h3_to_lat_lon() {
152152+ // Convert NYC to H3 and back
153153+ let h3_index = lat_lon_to_h3(40.7128, -74.0060).unwrap();
154154+ let (lat, lon) = h3_to_lat_lon(&h3_index).unwrap();
155155+156156+ // Should be close to original (within cell)
157157+ assert!((lat - 40.7128).abs() < 1.0);
158158+ assert!((lon - (-74.0060)).abs() < 1.0);
159159+ }
160160+161161+ #[test]
162162+ fn test_validate_h3_index() {
163163+ let h3_index = lat_lon_to_h3(40.7128, -74.0060).unwrap();
164164+ let result = validate_h3_index(&h3_index);
165165+ assert!(result.is_ok());
166166+ }
167167+168168+ #[test]
169169+ fn test_validate_h3_index_invalid() {
170170+ let result = validate_h3_index("invalid");
171171+ assert!(result.is_err());
172172+ }
173173+174174+ #[test]
175175+ fn test_h3_neighbors() {
176176+ let h3_index = lat_lon_to_h3(40.7128, -74.0060).unwrap();
177177+178178+ // k=0 should return just the cell itself
179179+ let neighbors_0 = h3_neighbors(&h3_index, 0).unwrap();
180180+ assert_eq!(neighbors_0.len(), 1);
181181+ assert_eq!(neighbors_0[0], h3_index);
182182+183183+ // k=1 should return the cell plus 6 neighbors (7 total)
184184+ let neighbors_1 = h3_neighbors(&h3_index, 1).unwrap();
185185+ assert_eq!(neighbors_1.len(), 7);
186186+ assert!(neighbors_1.contains(&h3_index));
187187+ }
188188+189189+ #[test]
190190+ fn test_h3_boundary() {
191191+ let h3_index = lat_lon_to_h3(40.7128, -74.0060).unwrap();
192192+ let boundary = h3_boundary(&h3_index).unwrap();
193193+194194+ // H3 cells are hexagons, so they have 6 vertices
195195+ assert_eq!(boundary.len(), 6);
196196+197197+ // All vertices should be valid coordinates
198198+ for (lat, lon) in &boundary {
199199+ assert!((-90.0..=90.0).contains(lat));
200200+ assert!((-180.0..=180.0).contains(lon));
201201+ }
202202+ }
203203+204204+ #[test]
205205+ fn test_h3_resolution() {
206206+ let h3_index = lat_lon_to_h3(40.7128, -74.0060).unwrap();
207207+ let resolution = h3_resolution(&h3_index).unwrap();
208208+ assert_eq!(resolution, 6); // Default resolution
209209+ }
210210+211211+ #[test]
212212+ fn test_h3_area_km2() {
213213+ let h3_index = lat_lon_to_h3(40.7128, -74.0060).unwrap();
214214+ let area = h3_area_km2(&h3_index).unwrap();
215215+216216+ // Resolution 6 cells are approximately 36 km^2
217217+ assert!(area > 30.0 && area < 50.0);
218218+ }
219219+220220+ #[test]
221221+ fn test_different_resolutions() {
222222+ // Test resolution 5 (larger cells)
223223+ let h3_res5 = lat_lon_to_h3_with_resolution(40.7128, -74.0060, Resolution::Five).unwrap();
224224+ let area_5 = h3_area_km2(&h3_res5).unwrap();
225225+226226+ // Test resolution 7 (smaller cells)
227227+ let h3_res7 = lat_lon_to_h3_with_resolution(40.7128, -74.0060, Resolution::Seven).unwrap();
228228+ let area_7 = h3_area_km2(&h3_res7).unwrap();
229229+230230+ // Resolution 5 cells should be larger than resolution 7
231231+ assert!(area_5 > area_7);
232232+ }
233233+}
+38
src/http/handle_edit_event.rs
···66use crate::atproto::auth::{
77 create_dpop_auth_from_aip_session, create_dpop_auth_from_oauth_session,
88};
99+use crate::search_index::SearchIndexManager;
910use crate::http::auth_utils::{require_valid_aip_session, AipSessionStatus};
1011use crate::atproto::utils::{location_from_address, location_from_geo};
1112use crate::http::context::UserRequestContext;
···433434 StatusCode::INTERNAL_SERVER_ERROR,
434435 Json(json!({"error": err.to_string()}))
435436 ).into_response());
437437+ }
438438+439439+ // Re-index the event in OpenSearch to update locations_geo and other fields
440440+ if let Some(endpoint) = &ctx.web_context.config.opensearch_endpoint {
441441+ if let Ok(manager) = SearchIndexManager::new(endpoint) {
442442+ // Fetch the updated event from the database
443443+ match event_get(&ctx.web_context.pool, &put_record_response.uri).await {
444444+ Ok(updated_event) => {
445445+ if let Err(err) = manager
446446+ .index_event(
447447+ &ctx.web_context.pool,
448448+ ctx.web_context.identity_resolver.clone(),
449449+ &updated_event,
450450+ )
451451+ .await
452452+ {
453453+ tracing::warn!(
454454+ ?err,
455455+ aturi = %put_record_response.uri,
456456+ "Failed to re-index event in OpenSearch after edit"
457457+ );
458458+ } else {
459459+ tracing::info!(
460460+ aturi = %put_record_response.uri,
461461+ "Successfully re-indexed event in OpenSearch after edit"
462462+ );
463463+ }
464464+ }
465465+ Err(err) => {
466466+ tracing::warn!(
467467+ ?err,
468468+ aturi = %put_record_response.uri,
469469+ "Failed to fetch updated event for OpenSearch indexing"
470470+ );
471471+ }
472472+ }
473473+ }
436474 }
437475438476 // Download and store header image from PDS if one was uploaded
···11+//! LFG form constants and validation utilities.
22+//!
33+//! This module provides constants for the Looking For Group (LFG) feature.
44+55+/// Allowed duration options in hours for LFG records.
66+pub(crate) const ALLOWED_DURATIONS: [u32; 5] = [6, 12, 24, 48, 72];
77+88+/// Default duration in hours for new LFG records.
99+pub(crate) const DEFAULT_DURATION_HOURS: u32 = 48;
1010+1111+/// Maximum number of tags allowed per LFG record.
1212+pub(crate) const MAX_TAGS: usize = 10;
1313+1414+/// Maximum length of a single tag.
1515+pub(crate) const MAX_TAG_LENGTH: usize = 64;
1616+1717+#[cfg(test)]
1818+mod tests {
1919+ use super::*;
2020+2121+ #[test]
2222+ fn test_allowed_durations() {
2323+ assert!(ALLOWED_DURATIONS.contains(&6));
2424+ assert!(ALLOWED_DURATIONS.contains(&12));
2525+ assert!(ALLOWED_DURATIONS.contains(&24));
2626+ assert!(ALLOWED_DURATIONS.contains(&48));
2727+ assert!(ALLOWED_DURATIONS.contains(&72));
2828+ assert!(!ALLOWED_DURATIONS.contains(&1));
2929+ assert!(!ALLOWED_DURATIONS.contains(&100));
3030+ }
3131+3232+ #[test]
3333+ fn test_default_duration() {
3434+ assert_eq!(DEFAULT_DURATION_HOURS, 48);
3535+ }
3636+3737+ #[test]
3838+ fn test_max_tags() {
3939+ assert_eq!(MAX_TAGS, 10);
4040+ }
4141+4242+ #[test]
4343+ fn test_max_tag_length() {
4444+ assert_eq!(MAX_TAG_LENGTH, 64);
4545+ }
4646+}
+3
src/http/mod.rs
···3838pub mod handle_finalize_acceptance;
3939pub mod handle_geo_aggregation;
4040pub mod handle_health;
4141+pub mod handle_lfg;
4242+pub mod h3_utils;
4143pub mod handle_host_meta;
4244pub mod handle_import;
4345pub mod handle_index;
···6769pub mod handle_xrpc_search_events;
6870pub mod handler_mcp;
6971pub mod import_utils;
7272+pub mod lfg_form;
7073pub mod location_edit_status;
7174pub mod macros;
7275pub mod middleware_auth;
···2828pub mod storage;
2929pub mod tap_processor;
3030pub mod task_identity_refresh;
3131+pub mod task_lfg_cleanup;
3132pub mod task_oauth_requests_cleanup;
3233pub mod task_search_indexer;
3334pub mod task_search_indexer_errors;
···2828pub(crate) struct NetworkStats {
2929 pub event_count: i64,
3030 pub rsvp_count: i64,
3131+ pub lfg_identities_count: i64,
3232+ pub lfg_locations_count: i64,
3133}
32343335impl NetworkStats {
···8284static IN_MEMORY_CACHE: once_cell::sync::Lazy<Arc<RwLock<InMemoryCache>>> =
8385 once_cell::sync::Lazy::new(|| Arc::new(RwLock::new(InMemoryCache::new())));
84868585-/// Query database for event and RSVP counts
8787+/// Query database for event, RSVP, and LFG counts
8688async fn query_stats(pool: &StoragePool) -> Result<NetworkStats, StatsError> {
8789 let row = sqlx::query(
8890 r#"
8991 SELECT
9092 (SELECT COUNT(*) FROM events) as event_count,
9191- (SELECT COUNT(*) FROM rsvps) as rsvp_count
9393+ (SELECT COUNT(*) FROM rsvps) as rsvp_count,
9494+ (SELECT COUNT(DISTINCT did) FROM atproto_records
9595+ WHERE collection = 'events.smokesignal.lfg'
9696+ AND (record->>'active')::boolean = true) as lfg_identities_count,
9797+ (SELECT COUNT(DISTINCT record->'location'->>'value') FROM atproto_records
9898+ WHERE collection = 'events.smokesignal.lfg'
9999+ AND (record->>'active')::boolean = true) as lfg_locations_count
92100 "#,
93101 )
94102 .fetch_one(pool)
···101109 .map_err(|e| StatsError::DatabaseError(e.to_string()))?,
102110 rsvp_count: row
103111 .try_get("rsvp_count")
112112+ .map_err(|e| StatsError::DatabaseError(e.to_string()))?,
113113+ lfg_identities_count: row
114114+ .try_get("lfg_identities_count")
115115+ .map_err(|e| StatsError::DatabaseError(e.to_string()))?,
116116+ lfg_locations_count: row
117117+ .try_get("lfg_locations_count")
104118 .map_err(|e| StatsError::DatabaseError(e.to_string()))?,
105119 })
106120}
+16-10
src/storage/atproto_record.rs
···183183 .await
184184 .map_err(StorageError::UnableToExecuteQuery)?;
185185186186- // Collect all suggestions with timestamps for sorting
187187- let mut suggestions_with_time: Vec<(DateTime<Utc>, LocationSuggestion)> = Vec::new();
186186+ // Collect all suggestions with (priority, timestamp) for sorting
187187+ // Priority: 0 = beaconbits/dropanchor (higher priority), 1 = smokesignal events
188188+ let mut suggestions_with_priority: Vec<(u8, DateTime<Utc>, LocationSuggestion)> = Vec::new();
188189189189- // Process atproto records (beaconbits/dropanchor)
190190+ // Process atproto records (beaconbits/dropanchor) - priority 0
190191 for (aturi, indexed_at, record) in atproto_rows {
191192 let r = &record.0;
192193 // Try addressDetails (beaconbits) first, then address (dropanchor)
···229230 .and_then(|v| v.as_str())
230231 .map(String::from),
231232 };
232232- suggestions_with_time.push((indexed_at, suggestion));
233233+ suggestions_with_priority.push((0, indexed_at, suggestion));
233234 }
234235235235- // Process event locations
236236+ // Process event locations - priority 1 (lower than beaconbits/dropanchor)
236237 for (aturi, updated_at, record) in event_rows {
237238 let timestamp = updated_at.unwrap_or_else(Utc::now);
238239 let locations = extract_locations_from_event(&aturi, &record.0);
239240 for suggestion in locations {
240240- suggestions_with_time.push((timestamp, suggestion));
241241+ suggestions_with_priority.push((1, timestamp, suggestion));
241242 }
242243 }
243244244244- // Sort by timestamp descending (most recent first)
245245- suggestions_with_time.sort_by(|a, b| b.0.cmp(&a.0));
245245+ // Sort by priority ascending (0 first), then by timestamp descending
246246+ suggestions_with_priority.sort_by(|a, b| {
247247+ match a.0.cmp(&b.0) {
248248+ std::cmp::Ordering::Equal => b.1.cmp(&a.1), // Same priority: newer first
249249+ other => other, // Different priority: lower first
250250+ }
251251+ });
246252247253 // Collect into OrderSet (deduplicates based on location fields)
248248- let suggestions: OrderSet<LocationSuggestion> = suggestions_with_time
254254+ let suggestions: OrderSet<LocationSuggestion> = suggestions_with_priority
249255 .into_iter()
250250- .map(|(_, s)| s)
256256+ .map(|(_, _, s)| s)
251257 .collect();
252258253259 Ok(suggestions)
+94
src/storage/lfg.rs
···11+//! Storage module for LFG (Looking For Group) records.
22+//!
33+//! This module provides query helpers for LFG records stored in the `atproto_records` table.
44+//! For writes, use `atproto_record_upsert()` and `atproto_record_delete()` from the
55+//! `atproto_record` module.
66+//!
77+//! Records are stored as `AtprotoRecord` and can be deserialized to `Lfg` using:
88+//! ```ignore
99+//! let lfg: Lfg = serde_json::from_value(record.record.0.clone())?;
1010+//! ```
1111+1212+use super::atproto_record::AtprotoRecord;
1313+use super::errors::StorageError;
1414+use super::StoragePool;
1515+use crate::atproto::lexicon::lfg::NSID;
1616+1717+/// Get the active LFG record for a DID from atproto_records.
1818+///
1919+/// Returns the most recent active LFG record for the given DID, or None if
2020+/// no active record exists.
2121+pub async fn lfg_get_active_by_did(
2222+ pool: &StoragePool,
2323+ did: &str,
2424+) -> Result<Option<AtprotoRecord>, StorageError> {
2525+ let record = sqlx::query_as::<_, AtprotoRecord>(
2626+ r#"
2727+ SELECT aturi, did, cid, collection, indexed_at, record
2828+ FROM atproto_records
2929+ WHERE did = $1
3030+ AND collection = $2
3131+ AND (record->>'active')::boolean = true
3232+ AND (record->>'endsAt')::timestamptz > NOW()
3333+ ORDER BY indexed_at DESC
3434+ LIMIT 1
3535+ "#,
3636+ )
3737+ .bind(did)
3838+ .bind(NSID)
3939+ .fetch_optional(pool)
4040+ .await
4141+ .map_err(StorageError::UnableToExecuteQuery)?;
4242+4343+ Ok(record)
4444+}
4545+4646+/// Get an LFG record by AT-URI from atproto_records.
4747+pub async fn lfg_get_by_aturi(
4848+ pool: &StoragePool,
4949+ aturi: &str,
5050+) -> Result<Option<AtprotoRecord>, StorageError> {
5151+ let record = sqlx::query_as::<_, AtprotoRecord>(
5252+ r#"
5353+ SELECT aturi, did, cid, collection, indexed_at, record
5454+ FROM atproto_records
5555+ WHERE aturi = $1
5656+ AND collection = $2
5757+ "#,
5858+ )
5959+ .bind(aturi)
6060+ .bind(NSID)
6161+ .fetch_optional(pool)
6262+ .await
6363+ .map_err(StorageError::UnableToExecuteQuery)?;
6464+6565+ Ok(record)
6666+}
6767+6868+/// Get all LFG records for a DID (including inactive/expired).
6969+///
7070+/// Used for tag history lookups.
7171+pub async fn lfg_get_all_by_did(
7272+ pool: &StoragePool,
7373+ did: &str,
7474+ limit: i64,
7575+) -> Result<Vec<AtprotoRecord>, StorageError> {
7676+ let records = sqlx::query_as::<_, AtprotoRecord>(
7777+ r#"
7878+ SELECT aturi, did, cid, collection, indexed_at, record
7979+ FROM atproto_records
8080+ WHERE did = $1
8181+ AND collection = $2
8282+ ORDER BY indexed_at DESC
8383+ LIMIT $3
8484+ "#,
8585+ )
8686+ .bind(did)
8787+ .bind(NSID)
8888+ .bind(limit)
8989+ .fetch_all(pool)
9090+ .await
9191+ .map_err(StorageError::UnableToExecuteQuery)?;
9292+9393+ Ok(records)
9494+}
+1
src/storage/mod.rs
···88pub mod errors;
99pub mod event;
1010pub mod identity_profile;
1111+pub mod lfg;
1112pub mod notification;
1213pub mod oauth;
1314pub mod private_event_content;
···11+//! LFG (Looking For Group) cleanup background task.
22+//!
33+//! This task runs periodically to deactivate expired LFG records in both
44+//! the database and OpenSearch index.
55+66+use anyhow::Result;
77+use chrono::{Duration, Utc};
88+use tokio::time::{Instant, sleep};
99+use tokio_util::sync::CancellationToken;
1010+1111+use crate::search_index::SearchIndexManager;
1212+use crate::storage::StoragePool;
1313+use crate::atproto::lexicon::lfg::NSID;
1414+1515+/// Configuration for the LFG cleanup task.
1616+pub struct LfgCleanupTaskConfig {
1717+ /// How often to run the cleanup (default: 1 hour)
1818+ pub sleep_interval: Duration,
1919+}
2020+2121+impl Default for LfgCleanupTaskConfig {
2222+ fn default() -> Self {
2323+ Self {
2424+ sleep_interval: Duration::hours(1),
2525+ }
2626+ }
2727+}
2828+2929+/// Background task that deactivates expired LFG records.
3030+pub struct LfgCleanupTask {
3131+ pub config: LfgCleanupTaskConfig,
3232+ pub storage_pool: StoragePool,
3333+ pub search_index: Option<SearchIndexManager>,
3434+ pub cancellation_token: CancellationToken,
3535+}
3636+3737+impl LfgCleanupTask {
3838+ /// Creates a new LFG cleanup task.
3939+ #[must_use]
4040+ pub fn new(
4141+ config: LfgCleanupTaskConfig,
4242+ storage_pool: StoragePool,
4343+ search_index: Option<SearchIndexManager>,
4444+ cancellation_token: CancellationToken,
4545+ ) -> Self {
4646+ Self {
4747+ config,
4848+ storage_pool,
4949+ search_index,
5050+ cancellation_token,
5151+ }
5252+ }
5353+5454+ /// Runs the LFG cleanup task as a long-running process.
5555+ ///
5656+ /// This task:
5757+ /// 1. Deactivates expired LFG records in the database
5858+ /// 2. Updates the OpenSearch index to reflect expired records
5959+ ///
6060+ /// # Errors
6161+ /// Returns an error if the sleep interval cannot be converted, or if there's
6262+ /// a problem cleaning up expired records.
6363+ pub async fn run(&self) -> Result<()> {
6464+ tracing::info!("LfgCleanupTask started");
6565+6666+ let interval = self.config.sleep_interval.to_std()?;
6767+6868+ let sleeper = sleep(interval);
6969+ tokio::pin!(sleeper);
7070+7171+ loop {
7272+ tokio::select! {
7373+ () = self.cancellation_token.cancelled() => {
7474+ break;
7575+ },
7676+ () = &mut sleeper => {
7777+ if let Err(err) = self.cleanup_expired_lfg_records().await {
7878+ tracing::error!("LfgCleanupTask failed: {}", err);
7979+ }
8080+ sleeper.as_mut().reset(Instant::now() + interval);
8181+ }
8282+ }
8383+ }
8484+8585+ tracing::info!("LfgCleanupTask stopped");
8686+8787+ Ok(())
8888+ }
8989+9090+ /// Cleanup expired LFG records.
9191+ async fn cleanup_expired_lfg_records(&self) -> Result<()> {
9292+ let now = Utc::now();
9393+9494+ tracing::debug!("Starting cleanup of expired LFG records");
9595+9696+ // Step 1: Update expired records in the database
9797+ let db_result = self.deactivate_expired_in_database(&now).await?;
9898+9999+ // Step 2: Update expired records in OpenSearch
100100+ let os_result = self.deactivate_expired_in_opensearch().await?;
101101+102102+ if db_result > 0 || os_result > 0 {
103103+ tracing::info!(
104104+ database_updated = db_result,
105105+ opensearch_updated = os_result,
106106+ "Cleaned up expired LFG records"
107107+ );
108108+ } else {
109109+ tracing::debug!("No expired LFG records to clean up");
110110+ }
111111+112112+ Ok(())
113113+ }
114114+115115+ /// Deactivate expired LFG records in the database.
116116+ async fn deactivate_expired_in_database(
117117+ &self,
118118+ now: &chrono::DateTime<Utc>,
119119+ ) -> Result<u64> {
120120+ // Query for active LFG records that have expired
121121+ let result = sqlx::query(
122122+ r#"
123123+ UPDATE atproto_records
124124+ SET record = jsonb_set(record, '{active}', 'false')
125125+ WHERE collection = $1
126126+ AND (record->>'active')::boolean = true
127127+ AND (record->>'endsAt')::timestamptz < $2
128128+ "#,
129129+ )
130130+ .bind(NSID)
131131+ .bind(now)
132132+ .execute(&self.storage_pool)
133133+ .await?;
134134+135135+ Ok(result.rows_affected())
136136+ }
137137+138138+ /// Deactivate expired LFG profiles in OpenSearch.
139139+ async fn deactivate_expired_in_opensearch(&self) -> Result<u64> {
140140+ let Some(ref search_index) = self.search_index else {
141141+ return Ok(0);
142142+ };
143143+144144+ match search_index.deactivate_expired_lfg_profiles().await {
145145+ Ok(count) => Ok(count),
146146+ Err(err) => {
147147+ tracing::warn!("Failed to deactivate expired LFG profiles in OpenSearch: {}", err);
148148+ Ok(0)
149149+ }
150150+ }
151151+ }
152152+}
153153+154154+#[cfg(test)]
155155+mod tests {
156156+ use super::*;
157157+158158+ #[test]
159159+ fn test_default_config() {
160160+ let config = LfgCleanupTaskConfig::default();
161161+ assert_eq!(config.sleep_interval, Duration::hours(1));
162162+ }
163163+}
+120-124
src/task_search_indexer.rs
···11use anyhow::Result;
22-use atproto_attestation::create_dagbor_cid;
32use atproto_identity::{model::Document, resolve::IdentityResolver, traits::DidDocumentStorage};
44-use atproto_record::lexicon::app::bsky::richtext::facet::{Facet, FacetFeature};
53use atproto_record::lexicon::community::lexicon::calendar::event::NSID as LexiconCommunityEventNSID;
64use opensearch::{
75 DeleteParts, IndexParts, OpenSearch,
86 http::transport::Transport,
97 indices::{IndicesCreateParts, IndicesExistsParts},
108};
1111-use serde::Deserialize;
129use serde_json::{Value, json};
1310use std::sync::Arc;
14111212+use crate::atproto::lexicon::lfg::{Lfg, NSID as LFG_NSID};
1513use crate::atproto::lexicon::profile::{Profile, NSID as PROFILE_NSID};
1614use crate::atproto::utils::get_profile_hashtags;
1515+use crate::search_index::SearchIndexManager;
1616+use crate::storage::event::event_get;
1717+use crate::storage::StoragePool;
1718use crate::task_search_indexer_errors::SearchIndexerError;
18191920/// Build an AT URI with pre-allocated capacity to avoid format! overhead.
···3031 uri
3132}
32333333-/// Generate a DAG-CBOR CID from a JSON value.
3434-///
3535-/// Creates a CIDv1 with DAG-CBOR codec (0x71) and SHA-256 hash (0x12),
3636-/// following the AT Protocol specification for content addressing.
3737-fn generate_location_cid(value: &Value) -> Result<String, SearchIndexerError> {
3838- let cid = create_dagbor_cid(value).map_err(|e| SearchIndexerError::CidGenerationFailed {
3939- error: e.to_string(),
4040- })?;
4141- Ok(cid.to_string())
4242-}
4343-4444-/// A lightweight event struct for search indexing.
4545-///
4646-/// Uses serde_json::Value for locations to avoid deserialization errors
4747-/// when event data contains location types not supported by the LocationOrRef enum.
4848-#[derive(Deserialize)]
4949-struct IndexableEvent {
5050- name: String,
5151- description: String,
5252- #[serde(rename = "createdAt")]
5353- created_at: chrono::DateTime<chrono::Utc>,
5454- #[serde(rename = "startsAt")]
5555- starts_at: Option<chrono::DateTime<chrono::Utc>>,
5656- #[serde(rename = "endsAt")]
5757- ends_at: Option<chrono::DateTime<chrono::Utc>>,
5858- #[serde(rename = "descriptionFacets")]
5959- facets: Option<Vec<Facet>>,
6060- /// Locations stored as raw JSON values for CID generation.
6161- #[serde(default)]
6262- locations: Vec<Value>,
6363-}
6464-6565-impl IndexableEvent {
6666- /// Extract hashtags from the event's facets
6767- fn get_hashtags(&self) -> Vec<String> {
6868- self.facets
6969- .as_ref()
7070- .map(|facets| {
7171- facets
7272- .iter()
7373- .flat_map(|facet| {
7474- facet.features.iter().filter_map(|feature| {
7575- if let FacetFeature::Tag(tag) = feature {
7676- Some(tag.tag.clone())
7777- } else {
7878- None
7979- }
8080- })
8181- })
8282- .collect()
8383- })
8484- .unwrap_or_default()
8585- }
8686-}
8787-8834const EVENTS_INDEX_NAME: &str = "smokesignal-events";
8935const PROFILES_INDEX_NAME: &str = "smokesignal-profiles";
3636+const LFG_INDEX_NAME: &str = "smokesignal-lfg-profile";
90379138pub struct SearchIndexer {
9239 client: Arc<OpenSearch>,
4040+ pool: StoragePool,
9341 identity_resolver: Arc<dyn IdentityResolver>,
9442 document_storage: Arc<dyn DidDocumentStorage + Send + Sync>,
4343+ event_index_manager: SearchIndexManager,
9544}
96459746impl SearchIndexer {
···10049 /// # Arguments
10150 ///
10251 /// * `endpoint` - OpenSearch endpoint URL
5252+ /// * `pool` - Database connection pool for fetching events
10353 /// * `identity_resolver` - Resolver for DID identities
10454 /// * `document_storage` - Storage for DID documents
10555 pub async fn new(
10656 endpoint: &str,
5757+ pool: StoragePool,
10758 identity_resolver: Arc<dyn IdentityResolver>,
10859 document_storage: Arc<dyn DidDocumentStorage + Send + Sync>,
10960 ) -> Result<Self> {
11061 let transport = Transport::single_node(endpoint)?;
11162 let client = Arc::new(OpenSearch::new(transport));
6363+ let event_index_manager = SearchIndexManager::new(endpoint)?;
1126411365 let indexer = Self {
11466 client,
6767+ pool,
11568 identity_resolver,
11669 document_storage,
7070+ event_index_manager,
11771 };
1187211973 indexer.ensure_index().await?;
···12680 self.ensure_events_index().await?;
12781 // Ensure profiles index
12882 self.ensure_profiles_index().await?;
8383+ // Ensure LFG profiles index
8484+ self.ensure_lfg_profiles_index().await?;
12985 Ok(())
13086 }
13187···151107 "description": { "type": "text" },
152108 "tags": { "type": "keyword" },
153109 "location_cids": { "type": "keyword" },
110110+ "locations_geo": { "type": "geo_point" },
154111 "start_time": { "type": "date" },
155112 "end_time": { "type": "date" },
156113 "created_at": { "type": "date" },
···222179 Ok(())
223180 }
224181182182+ async fn ensure_lfg_profiles_index(&self) -> Result<()> {
183183+ let exists_response = self
184184+ .client
185185+ .indices()
186186+ .exists(IndicesExistsParts::Index(&[LFG_INDEX_NAME]))
187187+ .send()
188188+ .await?;
189189+190190+ if exists_response.status_code().is_success() {
191191+ tracing::info!("OpenSearch index {} already exists", LFG_INDEX_NAME);
192192+ return Ok(());
193193+ }
194194+195195+ let index_body = json!({
196196+ "mappings": {
197197+ "properties": {
198198+ "aturi": { "type": "keyword" },
199199+ "did": { "type": "keyword" },
200200+ "location": { "type": "geo_point" },
201201+ "tags": { "type": "keyword" },
202202+ "starts_at": { "type": "date" },
203203+ "ends_at": { "type": "date" },
204204+ "active": { "type": "boolean" },
205205+ "created_at": { "type": "date" }
206206+ }
207207+ },
208208+ });
209209+210210+ let response = self
211211+ .client
212212+ .indices()
213213+ .create(IndicesCreateParts::Index(LFG_INDEX_NAME))
214214+ .body(index_body)
215215+ .send()
216216+ .await?;
217217+218218+ if response.status_code().is_success() {
219219+ tracing::info!("Created OpenSearch index {}", LFG_INDEX_NAME);
220220+ } else {
221221+ let error_body = response.text().await?;
222222+ return Err(SearchIndexerError::IndexCreationFailed { error_body }.into());
223223+ }
224224+225225+ Ok(())
226226+ }
227227+225228 /// Index a commit event (create or update).
226229 ///
227230 /// Dispatches to the appropriate indexer based on collection type.
···236239 match collection {
237240 "community.lexicon.calendar.event" => self.index_event(did, rkey, record).await,
238241 c if c == PROFILE_NSID => self.index_profile(did, rkey, record).await,
242242+ c if c == LFG_NSID => self.index_lfg_profile(did, rkey, record).await,
239243 _ => Ok(()),
240244 }
241245 }
···247251 match collection {
248252 "community.lexicon.calendar.event" => self.delete_event(did, rkey).await,
249253 c if c == PROFILE_NSID => self.delete_profile(did, rkey).await,
254254+ c if c == LFG_NSID => self.delete_lfg_profile(did, rkey).await,
250255 _ => Ok(()),
251256 }
252257 }
253258254254- async fn index_event(&self, did: &str, rkey: &str, record: Value) -> Result<()> {
255255- let event: IndexableEvent = serde_json::from_value(record)?;
256256-257257- let document = self.ensure_identity_stored(did).await?;
258258- let handle = document.handles().unwrap_or("invalid.handle");
259259-259259+ async fn index_event(&self, did: &str, rkey: &str, _record: Value) -> Result<()> {
260260 let aturi = build_aturi(did, LexiconCommunityEventNSID, rkey);
261261262262- // Extract fields from the IndexableEvent struct
263263- let name = &event.name;
264264- let description = &event.description;
265265- let created_at = &event.created_at;
266266- let starts_at = &event.starts_at;
267267- let ends_at = &event.ends_at;
268268-269269- // Extract hashtags from facets
270270- let tags = event.get_hashtags();
271271-272272- // Generate CIDs for each location
273273- let location_cids: Vec<String> = event
274274- .locations
275275- .iter()
276276- .filter_map(|loc| generate_location_cid(loc).ok())
277277- .collect();
278278-279279- let mut doc = json!({
280280- "did": did,
281281- "handle": handle,
282282- "name": name,
283283- "description": description,
284284- "tags": tags,
285285- "location_cids": location_cids,
286286- "created_at": json!(created_at),
287287- "updated_at": json!(chrono::Utc::now())
288288- });
289289-290290- // Add optional time fields
291291- if let Some(start) = starts_at {
292292- doc["start_time"] = json!(start);
293293- }
294294- if let Some(end) = ends_at {
295295- doc["end_time"] = json!(end);
296296- }
297297-298298- let response = self
299299- .client
300300- .index(IndexParts::IndexId(EVENTS_INDEX_NAME, &aturi))
301301- .body(doc)
302302- .send()
303303- .await?;
304304-305305- if response.status_code().is_success() {
306306- tracing::debug!("Indexed event {} for DID {}", rkey, did);
307307- } else {
308308- let error_body = response.text().await?;
309309- tracing::error!("Failed to index event: {}", error_body);
262262+ // Fetch the event from the database and delegate to SearchIndexManager
263263+ // This ensures we use the same indexing logic as the web handlers
264264+ match event_get(&self.pool, &aturi).await {
265265+ Ok(event) => {
266266+ self.event_index_manager
267267+ .index_event(&self.pool, self.identity_resolver.clone(), &event)
268268+ .await?;
269269+ tracing::debug!("Indexed event {} for DID {}", rkey, did);
270270+ }
271271+ Err(err) => {
272272+ // Event might not be in the database yet if content fetcher hasn't processed it
273273+ tracing::warn!(
274274+ "Could not fetch event {} for indexing: {}. It may be indexed on next update.",
275275+ aturi,
276276+ err
277277+ );
278278+ }
310279 }
311280312281 Ok(())
···315284 async fn delete_event(&self, did: &str, rkey: &str) -> Result<()> {
316285 let aturi = build_aturi(did, LexiconCommunityEventNSID, rkey);
317286318318- let response = self
319319- .client
320320- .delete(DeleteParts::IndexId(EVENTS_INDEX_NAME, &aturi))
321321- .send()
322322- .await?;
287287+ // Delegate to SearchIndexManager for consistent deletion logic
288288+ self.event_index_manager.delete_indexed_event(&aturi).await?;
323289324324- if response.status_code().is_success() || response.status_code() == 404 {
325325- tracing::debug!("Deleted event {} for DID {} from search index", rkey, did);
326326- } else {
327327- let error_body = response.text().await?;
328328- tracing::error!("Failed to delete event from index: {}", error_body);
329329- }
330330-290290+ tracing::debug!("Deleted event {} for DID {} from search index", rkey, did);
331291 Ok(())
332292 }
333293···389349 tracing::error!("Failed to delete profile from index: {}", error_body);
390350 }
391351352352+ Ok(())
353353+ }
354354+355355+ async fn index_lfg_profile(&self, did: &str, rkey: &str, record: Value) -> Result<()> {
356356+ let lfg: Lfg = serde_json::from_value(record)?;
357357+ let aturi = build_aturi(did, LFG_NSID, rkey);
358358+359359+ // Extract coordinates from location
360360+ let (lat, lon) = lfg.get_coordinates().unwrap_or((0.0, 0.0));
361361+362362+ // Delegate to SearchIndexManager for consistent indexing logic
363363+ self.event_index_manager
364364+ .index_lfg_profile(
365365+ &aturi,
366366+ did,
367367+ lat,
368368+ lon,
369369+ &lfg.tags,
370370+ &lfg.starts_at,
371371+ &lfg.ends_at,
372372+ &lfg.created_at,
373373+ lfg.active,
374374+ )
375375+ .await?;
376376+377377+ tracing::debug!("Indexed LFG profile {} for DID {}", rkey, did);
378378+ Ok(())
379379+ }
380380+381381+ async fn delete_lfg_profile(&self, did: &str, rkey: &str) -> Result<()> {
382382+ let aturi = build_aturi(did, LFG_NSID, rkey);
383383+384384+ // Delegate to SearchIndexManager for consistent deletion logic
385385+ self.event_index_manager.delete_lfg_profile(&aturi).await?;
386386+387387+ tracing::debug!("Deleted LFG profile {} for DID {} from search index", rkey, did);
392388 Ok(())
393389 }
394390
-7
src/task_search_indexer_errors.rs
···1212 /// and the operation fails with a server error response.
1313 #[error("error-smokesignal-search-indexer-1 Failed to create index: {error_body}")]
1414 IndexCreationFailed { error_body: String },
1515-1616- /// Error when CID generation fails.
1717- ///
1818- /// This error occurs when serializing location data to DAG-CBOR
1919- /// or generating the multihash for a CID fails.
2020- #[error("error-smokesignal-search-indexer-2 Failed to generate CID: {error}")]
2121- CidGenerationFailed { error: String },
2215}
+24-1
templates/en-us/create_event.alpine.html
···768768769769 init() {
770770 // Load existing location data from server if present
771771- {% if location_form.location_country %}
771771+ {% if event_locations %}
772772+ {% for loc in event_locations %}
773773+ this.formData.locations.push({
774774+ country: {{ loc.country | tojson }},
775775+ postal_code: {{ loc.postal_code | tojson }},
776776+ region: {{ loc.region | tojson }},
777777+ locality: {{ loc.locality | tojson }},
778778+ street: {{ loc.street | tojson }},
779779+ name: {{ loc.name | tojson }},
780780+ });
781781+ {% endfor %}
782782+ {% elif location_form.location_country %}
783783+ // Fallback to old single-location format
772784 this.formData.locations.push({
773785 country: {{ location_form.location_country | tojson }},
774786 postal_code: {% if location_form.location_postal_code %}{{ location_form.location_postal_code | tojson }}{% else %}null{% endif %},
···777789 street: {% if location_form.location_street %}{{ location_form.location_street | tojson }}{% else %}null{% endif %},
778790 name: {% if location_form.location_name %}{{ location_form.location_name | tojson }}{% else %}null{% endif %},
779791 });
792792+ {% endif %}
793793+794794+ // Load existing geo locations from server if present
795795+ {% if event_geo_locations %}
796796+ {% for geo in event_geo_locations %}
797797+ this.formData.geo_locations.push({
798798+ latitude: {{ geo.latitude | tojson }},
799799+ longitude: {{ geo.longitude | tojson }},
800800+ name: {{ geo.name | tojson }},
801801+ });
802802+ {% endfor %}
780803 {% endif %}
781804782805 // Load existing links from server if present
+10
templates/en-us/event_list.incl.html
···158158 <p>{% autoescape false %}{{ event.description_short }}{% endautoescape %}</p>
159159 </div>
160160161161+ {% if user_tags and event.description_tags %}
162162+ <div class="tags">
163163+ {% for tag in event.description_tags %}
164164+ {% if tag | lower in user_tags %}
165165+ <span class="tag is-info is-small">{{ tag }}</span>
166166+ {% endif %}
167167+ {% endfor %}
168168+ </div>
169169+ {% endif %}
170170+161171 </div>
162172</article>
163173
+2-2
templates/en-us/index.common.html
···4848 <span class="icon">
4949 <i class="fas fa-check-circle"></i>
5050 </span>
5151- <span>A network of {{ event_count }} events and {{ rsvp_count }} RSVPs</span>
5151+ <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>
5252 </p>
5353 </div>
5454 </div>
···278278279279 // Default center (world view) if geolocation fails
280280 const DEFAULT_CENTER = [39.8283, -98.5795]; // Center of USA
281281- const DEFAULT_ZOOM = 9;
281281+ const DEFAULT_ZOOM = 10;
282282283283 // Color scale for heatmap (low to high)
284284 const colors = ['#ffffcc', '#ffeda0', '#fed976', '#feb24c', '#fd8d3c', '#fc4e2a', '#e31a1c', '#bd0026', '#800026'];
···11+{% extends "en-us/base.html" %}
22+{% block title %}Looking For Group - Smoke Signal{% endblock %}
33+{% block head %}
44+<meta name="description" content="Find activity partners in your area with Looking For Group">
55+<meta property="og:title" content="Looking For Group">
66+<meta property="og:description" content="Find activity partners in your area with Looking For Group">
77+<meta property="og:site_name" content="Smoke Signal" />
88+<meta property="og:type" content="website" />
99+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
1010+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
1111+<script src="https://unpkg.com/h3-js@4"></script>
1212+{% endblock %}
1313+{% block content %}
1414+{% include 'en-us/lfg_form.common.html' %}
1515+{% endblock %}
···11+{% extends "en-us/base.html" %}
22+{% block title %}Looking For Group - Smoke Signal{% endblock %}
33+{% block head %}
44+<meta name="description" content="Find activity partners in your area with Looking For Group">
55+<meta property="og:title" content="Looking For Group">
66+<meta property="og:description" content="Find activity partners in your area with Looking For Group">
77+<meta property="og:site_name" content="Smoke Signal" />
88+<meta property="og:type" content="website" />
99+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
1010+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
1111+<script src="https://unpkg.com/h3-js@4"></script>
1212+{% endblock %}
1313+{% block content %}
1414+{% include 'en-us/lfg_matches.common.html' %}
1515+{% endblock %}