+1
Cargo.toml
+1
Cargo.toml
+52
lexicon/events.smokesignal.lfg.json
+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
+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
src/atproto/lexicon/mod.rs
+1
src/bin/smokesignal.rs
+1
src/bin/smokesignal.rs
+92
src/http/auth_utils.rs
+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
+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
+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
+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
+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
+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
+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
+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
+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, ¤t_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, ¤t_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
+
¤t_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
+
¤t_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
+
¤t_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, ¤t_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
+
¤t_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
+
¤t_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
+
¤t_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, ¤t_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, ¤t_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
+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
+1
src/http/handle_oauth_aip_login.rs
+46
src/http/lfg_form.rs
+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
+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
+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
+1
src/lib.rs
+472
-1
src/search_index.rs
+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
+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
+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
+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
+1
src/storage/mod.rs
+1
src/tap_processor.rs
+1
src/tap_processor.rs
+163
src/task_lfg_cleanup.rs
+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
+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
-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
+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
+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
+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
+4
templates/en-us/lfg_form.bare.html
+329
templates/en-us/lfg_form.common.html
+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: '© 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
+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
+4
templates/en-us/lfg_matches.bare.html
+194
templates/en-us/lfg_matches.common.html
+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: '© 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
+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 %}