···19 pub suggestions: OrderSet<LocationSuggestion>,
20}
2122-/// Handler to fetch beaconbits location suggestions for the authenticated user
23///
24/// GET /event/location-suggestions
25///
26-/// Returns the user's 5 most recent beaconbits locations (bookmarks and beacons)
27-/// that can be used to pre-populate event location fields.
00028pub(crate) async fn handle_location_suggestions(
29 State(web_context): State<WebContext>,
30 Cached(auth): Cached<Auth>,
···43 }
44 };
4546- // Fetch location suggestions (limit to 5 most recent)
47 let suggestions =
48- atproto_record_get_location_suggestions(&web_context.pool, ¤t_handle.did, 5)
49 .await
50 .unwrap_or_else(|err| {
51 tracing::warn!(
···19 pub suggestions: OrderSet<LocationSuggestion>,
20}
2122+/// Handler to fetch location suggestions for the authenticated user
23///
24/// GET /event/location-suggestions
25///
26+/// Returns the user's recent locations from multiple sources:
27+/// - Up to 100 most recent beaconbits/dropanchor locations
28+/// - Locations from up to 100 most recent events created by the user
29+///
30+/// Results are deduplicated and can be used to pre-populate event location fields.
31pub(crate) async fn handle_location_suggestions(
32 State(web_context): State<WebContext>,
33 Cached(auth): Cached<Auth>,
···46 }
47 };
4849+ // Fetch location suggestions from all sources
50 let suggestions =
51+ atproto_record_get_location_suggestions(&web_context.pool, ¤t_handle.did)
52 .await
53 .unwrap_or_else(|err| {
54 tracing::warn!(
+169-67
src/storage/atproto_record.rs
···130131/// Query location records for a user, returning most recent locations.
132///
133-/// Queries `app.beaconbits.bookmark.item`, `app.beaconbits.beacon`, and `app.dropanchor.checkin`
134-/// collections, orders by indexed_at descending, and limits to `count` results. Duplicates are
135-/// removed based on location fields (name, street, locality, region, postal_code, country).
000136pub async fn atproto_record_get_location_suggestions(
137 pool: &StoragePool,
138 did: &str,
139- count: i64,
140) -> Result<OrderSet<LocationSuggestion>, StorageError> {
141- let rows: Vec<(String, sqlx::types::Json<serde_json::Value>)> = sqlx::query_as(
142- r#"
143- SELECT aturi, record
144- FROM atproto_records
145- WHERE did = $1
146- AND collection IN (
147- 'app.beaconbits.bookmark.item',
148- 'app.beaconbits.beacon',
149- 'app.dropanchor.checkin'
150- )
151- ORDER BY indexed_at DESC
152- LIMIT $2
153- "#,
154- )
155- .bind(did)
156- .bind(count)
157- .fetch_all(pool)
158- .await
159- .map_err(StorageError::UnableToExecuteQuery)?;
160161- let suggestions: OrderSet<LocationSuggestion> = rows
162- .into_iter()
163- .map(|(aturi, record)| {
164- let r = &record.0;
165- // Try addressDetails (beaconbits) first, then address (dropanchor)
166- let address = r.get("addressDetails").or_else(|| r.get("address"));
167- // Try location (beaconbits) first, then geo (dropanchor)
168- let geo = r.get("location").or_else(|| r.get("geo"));
0000000000000169170- LocationSuggestion {
171- source: Some(aturi),
172- name: address
173- .and_then(|a| a.get("name"))
174- .and_then(|v| v.as_str())
175- .map(String::from),
176- street: address
177- .and_then(|a| a.get("street"))
178- .and_then(|v| v.as_str())
179- .map(String::from),
180- locality: address
181- .and_then(|a| a.get("locality"))
182- .and_then(|v| v.as_str())
183- .map(String::from),
184- region: address
185- .and_then(|a| a.get("region"))
186- .and_then(|v| v.as_str())
187- .map(String::from),
188- postal_code: address
189- .and_then(|a| a.get("postalCode"))
190- .and_then(|v| v.as_str())
191- .map(String::from),
192- country: address
193- .and_then(|a| a.get("country"))
194- .and_then(|v| v.as_str())
195- .map(String::from),
196- latitude: geo
197- .and_then(|g| g.get("latitude"))
198- .and_then(|v| v.as_str())
199- .map(String::from),
200- longitude: geo
201- .and_then(|g| g.get("longitude"))
202- .and_then(|v| v.as_str())
203- .map(String::from),
204- }
205- })
00000000000000000000000000000000000000000000000206 .collect();
207208 Ok(suggestions)
209}
000000000000000000000000000000000000000000000000000000000
···130131/// Query location records for a user, returning most recent locations.
132///
133+/// Queries locations from multiple sources:
134+/// - Up to 100 most recent `app.beaconbits.bookmark.item`, `app.beaconbits.beacon`, `app.dropanchor.checkin` from atproto_records
135+/// - Locations from up to 100 most recent events created by the user
136+///
137+/// Results are ordered by timestamp descending and deduplicated based on location fields
138+/// (name, street, locality, region, postal_code, country, latitude, longitude).
139pub async fn atproto_record_get_location_suggestions(
140 pool: &StoragePool,
141 did: &str,
0142) -> Result<OrderSet<LocationSuggestion>, StorageError> {
143+ // Fetch up to 100 records from each source
144+ let fetch_limit: i64 = 100;
00000000000000000145146+ // Query 1: beaconbits/dropanchor from atproto_records
147+ let atproto_rows: Vec<(String, DateTime<Utc>, sqlx::types::Json<serde_json::Value>)> =
148+ sqlx::query_as(
149+ r#"
150+ SELECT aturi, indexed_at, record
151+ FROM atproto_records
152+ WHERE did = $1
153+ AND collection IN (
154+ 'app.beaconbits.bookmark.item',
155+ 'app.beaconbits.beacon',
156+ 'app.dropanchor.checkin'
157+ )
158+ ORDER BY indexed_at DESC
159+ LIMIT $2
160+ "#,
161+ )
162+ .bind(did)
163+ .bind(fetch_limit)
164+ .fetch_all(pool)
165+ .await
166+ .map_err(StorageError::UnableToExecuteQuery)?;
167168+ // Query 2: events with locations created by this user
169+ let event_rows: Vec<(String, Option<DateTime<Utc>>, sqlx::types::Json<serde_json::Value>)> =
170+ sqlx::query_as(
171+ r#"
172+ SELECT aturi, updated_at, record
173+ FROM events
174+ WHERE did = $1
175+ AND json_array_length(COALESCE(record->'locations', '[]'::json)) > 0
176+ ORDER BY updated_at DESC NULLS LAST
177+ LIMIT $2
178+ "#,
179+ )
180+ .bind(did)
181+ .bind(fetch_limit)
182+ .fetch_all(pool)
183+ .await
184+ .map_err(StorageError::UnableToExecuteQuery)?;
185+186+ // Collect all suggestions with timestamps for sorting
187+ let mut suggestions_with_time: Vec<(DateTime<Utc>, LocationSuggestion)> = Vec::new();
188+189+ // Process atproto records (beaconbits/dropanchor)
190+ for (aturi, indexed_at, record) in atproto_rows {
191+ let r = &record.0;
192+ // Try addressDetails (beaconbits) first, then address (dropanchor)
193+ let address = r.get("addressDetails").or_else(|| r.get("address"));
194+ // Try location (beaconbits) first, then geo (dropanchor)
195+ let geo = r.get("location").or_else(|| r.get("geo"));
196+197+ let suggestion = LocationSuggestion {
198+ source: Some(aturi),
199+ name: address
200+ .and_then(|a| a.get("name"))
201+ .and_then(|v| v.as_str())
202+ .map(String::from),
203+ street: address
204+ .and_then(|a| a.get("street"))
205+ .and_then(|v| v.as_str())
206+ .map(String::from),
207+ locality: address
208+ .and_then(|a| a.get("locality"))
209+ .and_then(|v| v.as_str())
210+ .map(String::from),
211+ region: address
212+ .and_then(|a| a.get("region"))
213+ .and_then(|v| v.as_str())
214+ .map(String::from),
215+ postal_code: address
216+ .and_then(|a| a.get("postalCode"))
217+ .and_then(|v| v.as_str())
218+ .map(String::from),
219+ country: address
220+ .and_then(|a| a.get("country"))
221+ .and_then(|v| v.as_str())
222+ .map(String::from),
223+ latitude: geo
224+ .and_then(|g| g.get("latitude"))
225+ .and_then(|v| v.as_str())
226+ .map(String::from),
227+ longitude: geo
228+ .and_then(|g| g.get("longitude"))
229+ .and_then(|v| v.as_str())
230+ .map(String::from),
231+ };
232+ suggestions_with_time.push((indexed_at, suggestion));
233+ }
234+235+ // Process event locations
236+ for (aturi, updated_at, record) in event_rows {
237+ let timestamp = updated_at.unwrap_or_else(Utc::now);
238+ let locations = extract_locations_from_event(&aturi, &record.0);
239+ for suggestion in locations {
240+ suggestions_with_time.push((timestamp, suggestion));
241+ }
242+ }
243+244+ // Sort by timestamp descending (most recent first)
245+ suggestions_with_time.sort_by(|a, b| b.0.cmp(&a.0));
246+247+ // Collect into OrderSet (deduplicates based on location fields)
248+ let suggestions: OrderSet<LocationSuggestion> = suggestions_with_time
249+ .into_iter()
250+ .map(|(_, s)| s)
251 .collect();
252253 Ok(suggestions)
254}
255+256+/// Extract locations from an event record's locations array.
257+///
258+/// Handles both address and geo location types based on the `$type` field.
259+fn extract_locations_from_event(aturi: &str, record: &serde_json::Value) -> Vec<LocationSuggestion> {
260+ let Some(locations) = record.get("locations").and_then(|l| l.as_array()) else {
261+ return vec![];
262+ };
263+264+ locations
265+ .iter()
266+ .filter_map(|loc| {
267+ let loc_type = loc.get("$type").and_then(|t| t.as_str())?;
268+269+ match loc_type {
270+ "community.lexicon.location.address" => Some(LocationSuggestion {
271+ source: Some(aturi.to_string()),
272+ name: loc.get("name").and_then(|v| v.as_str()).map(String::from),
273+ street: loc.get("street").and_then(|v| v.as_str()).map(String::from),
274+ locality: loc
275+ .get("locality")
276+ .and_then(|v| v.as_str())
277+ .map(String::from),
278+ region: loc.get("region").and_then(|v| v.as_str()).map(String::from),
279+ postal_code: loc
280+ .get("postalCode")
281+ .and_then(|v| v.as_str())
282+ .map(String::from),
283+ country: loc
284+ .get("country")
285+ .and_then(|v| v.as_str())
286+ .map(String::from),
287+ latitude: None,
288+ longitude: None,
289+ }),
290+ "community.lexicon.location.geo" => Some(LocationSuggestion {
291+ source: Some(aturi.to_string()),
292+ name: loc.get("name").and_then(|v| v.as_str()).map(String::from),
293+ street: None,
294+ locality: None,
295+ region: None,
296+ postal_code: None,
297+ country: None,
298+ latitude: loc
299+ .get("latitude")
300+ .and_then(|v| v.as_str())
301+ .map(String::from),
302+ longitude: loc
303+ .get("longitude")
304+ .and_then(|v| v.as_str())
305+ .map(String::from),
306+ }),
307+ _ => None,
308+ }
309+ })
310+ .collect()
311+}