···1919 pub suggestions: OrderSet<LocationSuggestion>,
2020}
21212222-/// Handler to fetch beaconbits location suggestions for the authenticated user
2222+/// Handler to fetch location suggestions for the authenticated user
2323///
2424/// GET /event/location-suggestions
2525///
2626-/// Returns the user's 5 most recent beaconbits locations (bookmarks and beacons)
2727-/// that can be used to pre-populate event location fields.
2626+/// Returns the user's recent locations from multiple sources:
2727+/// - Up to 100 most recent beaconbits/dropanchor locations
2828+/// - Locations from up to 100 most recent events created by the user
2929+///
3030+/// Results are deduplicated and can be used to pre-populate event location fields.
2831pub(crate) async fn handle_location_suggestions(
2932 State(web_context): State<WebContext>,
3033 Cached(auth): Cached<Auth>,
···4346 }
4447 };
45484646- // Fetch location suggestions (limit to 5 most recent)
4949+ // Fetch location suggestions from all sources
4750 let suggestions =
4848- atproto_record_get_location_suggestions(&web_context.pool, ¤t_handle.did, 5)
5151+ atproto_record_get_location_suggestions(&web_context.pool, ¤t_handle.did)
4952 .await
5053 .unwrap_or_else(|err| {
5154 tracing::warn!(
+169-67
src/storage/atproto_record.rs
···130130131131/// Query location records for a user, returning most recent locations.
132132///
133133-/// Queries `app.beaconbits.bookmark.item`, `app.beaconbits.beacon`, and `app.dropanchor.checkin`
134134-/// collections, orders by indexed_at descending, and limits to `count` results. Duplicates are
135135-/// removed based on location fields (name, street, locality, region, postal_code, country).
133133+/// Queries locations from multiple sources:
134134+/// - Up to 100 most recent `app.beaconbits.bookmark.item`, `app.beaconbits.beacon`, `app.dropanchor.checkin` from atproto_records
135135+/// - Locations from up to 100 most recent events created by the user
136136+///
137137+/// Results are ordered by timestamp descending and deduplicated based on location fields
138138+/// (name, street, locality, region, postal_code, country, latitude, longitude).
136139pub async fn atproto_record_get_location_suggestions(
137140 pool: &StoragePool,
138141 did: &str,
139139- count: i64,
140142) -> Result<OrderSet<LocationSuggestion>, StorageError> {
141141- let rows: Vec<(String, sqlx::types::Json<serde_json::Value>)> = sqlx::query_as(
142142- r#"
143143- SELECT aturi, record
144144- FROM atproto_records
145145- WHERE did = $1
146146- AND collection IN (
147147- 'app.beaconbits.bookmark.item',
148148- 'app.beaconbits.beacon',
149149- 'app.dropanchor.checkin'
150150- )
151151- ORDER BY indexed_at DESC
152152- LIMIT $2
153153- "#,
154154- )
155155- .bind(did)
156156- .bind(count)
157157- .fetch_all(pool)
158158- .await
159159- .map_err(StorageError::UnableToExecuteQuery)?;
143143+ // Fetch up to 100 records from each source
144144+ let fetch_limit: i64 = 100;
160145161161- let suggestions: OrderSet<LocationSuggestion> = rows
162162- .into_iter()
163163- .map(|(aturi, record)| {
164164- let r = &record.0;
165165- // Try addressDetails (beaconbits) first, then address (dropanchor)
166166- let address = r.get("addressDetails").or_else(|| r.get("address"));
167167- // Try location (beaconbits) first, then geo (dropanchor)
168168- let geo = r.get("location").or_else(|| r.get("geo"));
146146+ // Query 1: beaconbits/dropanchor from atproto_records
147147+ let atproto_rows: Vec<(String, DateTime<Utc>, sqlx::types::Json<serde_json::Value>)> =
148148+ sqlx::query_as(
149149+ r#"
150150+ SELECT aturi, indexed_at, record
151151+ FROM atproto_records
152152+ WHERE did = $1
153153+ AND collection IN (
154154+ 'app.beaconbits.bookmark.item',
155155+ 'app.beaconbits.beacon',
156156+ 'app.dropanchor.checkin'
157157+ )
158158+ ORDER BY indexed_at DESC
159159+ LIMIT $2
160160+ "#,
161161+ )
162162+ .bind(did)
163163+ .bind(fetch_limit)
164164+ .fetch_all(pool)
165165+ .await
166166+ .map_err(StorageError::UnableToExecuteQuery)?;
169167170170- LocationSuggestion {
171171- source: Some(aturi),
172172- name: address
173173- .and_then(|a| a.get("name"))
174174- .and_then(|v| v.as_str())
175175- .map(String::from),
176176- street: address
177177- .and_then(|a| a.get("street"))
178178- .and_then(|v| v.as_str())
179179- .map(String::from),
180180- locality: address
181181- .and_then(|a| a.get("locality"))
182182- .and_then(|v| v.as_str())
183183- .map(String::from),
184184- region: address
185185- .and_then(|a| a.get("region"))
186186- .and_then(|v| v.as_str())
187187- .map(String::from),
188188- postal_code: address
189189- .and_then(|a| a.get("postalCode"))
190190- .and_then(|v| v.as_str())
191191- .map(String::from),
192192- country: address
193193- .and_then(|a| a.get("country"))
194194- .and_then(|v| v.as_str())
195195- .map(String::from),
196196- latitude: geo
197197- .and_then(|g| g.get("latitude"))
198198- .and_then(|v| v.as_str())
199199- .map(String::from),
200200- longitude: geo
201201- .and_then(|g| g.get("longitude"))
202202- .and_then(|v| v.as_str())
203203- .map(String::from),
204204- }
205205- })
168168+ // Query 2: events with locations created by this user
169169+ let event_rows: Vec<(String, Option<DateTime<Utc>>, sqlx::types::Json<serde_json::Value>)> =
170170+ sqlx::query_as(
171171+ r#"
172172+ SELECT aturi, updated_at, record
173173+ FROM events
174174+ WHERE did = $1
175175+ AND json_array_length(COALESCE(record->'locations', '[]'::json)) > 0
176176+ ORDER BY updated_at DESC NULLS LAST
177177+ LIMIT $2
178178+ "#,
179179+ )
180180+ .bind(did)
181181+ .bind(fetch_limit)
182182+ .fetch_all(pool)
183183+ .await
184184+ .map_err(StorageError::UnableToExecuteQuery)?;
185185+186186+ // Collect all suggestions with timestamps for sorting
187187+ let mut suggestions_with_time: Vec<(DateTime<Utc>, LocationSuggestion)> = Vec::new();
188188+189189+ // Process atproto records (beaconbits/dropanchor)
190190+ for (aturi, indexed_at, record) in atproto_rows {
191191+ let r = &record.0;
192192+ // Try addressDetails (beaconbits) first, then address (dropanchor)
193193+ let address = r.get("addressDetails").or_else(|| r.get("address"));
194194+ // Try location (beaconbits) first, then geo (dropanchor)
195195+ let geo = r.get("location").or_else(|| r.get("geo"));
196196+197197+ let suggestion = LocationSuggestion {
198198+ source: Some(aturi),
199199+ name: address
200200+ .and_then(|a| a.get("name"))
201201+ .and_then(|v| v.as_str())
202202+ .map(String::from),
203203+ street: address
204204+ .and_then(|a| a.get("street"))
205205+ .and_then(|v| v.as_str())
206206+ .map(String::from),
207207+ locality: address
208208+ .and_then(|a| a.get("locality"))
209209+ .and_then(|v| v.as_str())
210210+ .map(String::from),
211211+ region: address
212212+ .and_then(|a| a.get("region"))
213213+ .and_then(|v| v.as_str())
214214+ .map(String::from),
215215+ postal_code: address
216216+ .and_then(|a| a.get("postalCode"))
217217+ .and_then(|v| v.as_str())
218218+ .map(String::from),
219219+ country: address
220220+ .and_then(|a| a.get("country"))
221221+ .and_then(|v| v.as_str())
222222+ .map(String::from),
223223+ latitude: geo
224224+ .and_then(|g| g.get("latitude"))
225225+ .and_then(|v| v.as_str())
226226+ .map(String::from),
227227+ longitude: geo
228228+ .and_then(|g| g.get("longitude"))
229229+ .and_then(|v| v.as_str())
230230+ .map(String::from),
231231+ };
232232+ suggestions_with_time.push((indexed_at, suggestion));
233233+ }
234234+235235+ // Process event locations
236236+ for (aturi, updated_at, record) in event_rows {
237237+ let timestamp = updated_at.unwrap_or_else(Utc::now);
238238+ let locations = extract_locations_from_event(&aturi, &record.0);
239239+ for suggestion in locations {
240240+ suggestions_with_time.push((timestamp, suggestion));
241241+ }
242242+ }
243243+244244+ // Sort by timestamp descending (most recent first)
245245+ suggestions_with_time.sort_by(|a, b| b.0.cmp(&a.0));
246246+247247+ // Collect into OrderSet (deduplicates based on location fields)
248248+ let suggestions: OrderSet<LocationSuggestion> = suggestions_with_time
249249+ .into_iter()
250250+ .map(|(_, s)| s)
206251 .collect();
207252208253 Ok(suggestions)
209254}
255255+256256+/// Extract locations from an event record's locations array.
257257+///
258258+/// Handles both address and geo location types based on the `$type` field.
259259+fn extract_locations_from_event(aturi: &str, record: &serde_json::Value) -> Vec<LocationSuggestion> {
260260+ let Some(locations) = record.get("locations").and_then(|l| l.as_array()) else {
261261+ return vec![];
262262+ };
263263+264264+ locations
265265+ .iter()
266266+ .filter_map(|loc| {
267267+ let loc_type = loc.get("$type").and_then(|t| t.as_str())?;
268268+269269+ match loc_type {
270270+ "community.lexicon.location.address" => Some(LocationSuggestion {
271271+ source: Some(aturi.to_string()),
272272+ name: loc.get("name").and_then(|v| v.as_str()).map(String::from),
273273+ street: loc.get("street").and_then(|v| v.as_str()).map(String::from),
274274+ locality: loc
275275+ .get("locality")
276276+ .and_then(|v| v.as_str())
277277+ .map(String::from),
278278+ region: loc.get("region").and_then(|v| v.as_str()).map(String::from),
279279+ postal_code: loc
280280+ .get("postalCode")
281281+ .and_then(|v| v.as_str())
282282+ .map(String::from),
283283+ country: loc
284284+ .get("country")
285285+ .and_then(|v| v.as_str())
286286+ .map(String::from),
287287+ latitude: None,
288288+ longitude: None,
289289+ }),
290290+ "community.lexicon.location.geo" => Some(LocationSuggestion {
291291+ source: Some(aturi.to_string()),
292292+ name: loc.get("name").and_then(|v| v.as_str()).map(String::from),
293293+ street: None,
294294+ locality: None,
295295+ region: None,
296296+ postal_code: None,
297297+ country: None,
298298+ latitude: loc
299299+ .get("latitude")
300300+ .and_then(|v| v.as_str())
301301+ .map(String::from),
302302+ longitude: loc
303303+ .get("longitude")
304304+ .and_then(|v| v.as_str())
305305+ .map(String::from),
306306+ }),
307307+ _ => None,
308308+ }
309309+ })
310310+ .collect()
311311+}