Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t
at main 379 lines 13 kB view raw
1// Event hydration service for filtering results 2// 3// Enriches filtered event data with additional information like 4// RSVP counts, creator handles, and related data needed for display. 5 6use std::collections::HashMap; 7use sqlx::PgPool; 8use tracing::{instrument, trace}; 9use unic_langid::LanguageIdentifier; 10 11use super::FilterError; 12use crate::http::event_view::EventView; 13use crate::storage::event::model::Event; 14use crate::storage::handle::{handle_for_did, model::Handle}; 15 16/// Service for hydrating event data with additional information 17#[derive(Debug, Clone)] 18pub struct EventHydrator { 19 pool: PgPool, 20} 21 22/// Hydration options to control what data to include 23#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] 24pub struct HydrationOptions { 25 /// Include RSVP counts for each event 26 pub include_rsvp_counts: bool, 27 28 /// Include creator handle information 29 pub include_creator_handles: bool, 30 31 /// Include location details 32 pub include_locations: bool, 33 34 /// Maximum number of events to hydrate (for performance) 35 pub max_events: Option<usize>, 36} 37 38/// Hydrated event with additional data 39#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 40pub struct HydratedEvent { 41 pub event: Event, 42 pub event_view: Option<EventView>, 43 pub creator_handle: Option<Handle>, 44 pub rsvp_counts: Option<RsvpCounts>, 45} 46 47/// RSVP count information 48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] 49pub struct RsvpCounts { 50 pub going: i64, 51 pub interested: i64, 52 pub not_going: i64, 53 pub total: i64, 54} 55 56impl EventHydrator { 57 /// Create a new event hydrator 58 pub fn new(pool: PgPool) -> Self { 59 Self { pool } 60 } 61 62 /// Hydrate a list of events with additional data 63 #[instrument(skip(self, events, options), fields(event_count = events.len()))] 64 pub async fn hydrate_events( 65 &self, 66 events: Vec<Event>, 67 options: &HydrationOptions, 68 ) -> Result<Vec<HydratedEvent>, FilterError> { 69 self.hydrate_events_with_locale(events, options, None).await 70 } 71 72 /// Hydrate a list of events with additional data including locale-aware formatting 73 #[instrument(skip(self, events, options, locale), fields(event_count = events.len()))] 74 pub async fn hydrate_events_with_locale( 75 &self, 76 events: Vec<Event>, 77 options: &HydrationOptions, 78 locale: Option<&str>, 79 ) -> Result<Vec<HydratedEvent>, FilterError> { 80 let mut hydrated_events = Vec::new(); 81 82 // Limit the number of events to hydrate for performance 83 let events_to_process = if let Some(max) = options.max_events { 84 events.into_iter().take(max).collect() 85 } else { 86 events 87 }; 88 89 // Batch fetch creator handles if needed 90 let creator_handles = if options.include_creator_handles { 91 self.fetch_creator_handles(&events_to_process).await? 92 } else { 93 HashMap::new() 94 }; 95 96 // Batch fetch RSVP counts if needed 97 let rsvp_counts = if options.include_rsvp_counts { 98 self.fetch_rsvp_counts(&events_to_process).await? 99 } else { 100 HashMap::new() 101 }; 102 103 // Hydrate each event 104 for event in events_to_process { 105 let event_view = if options.include_rsvp_counts { 106 // Convert to EventView for RSVP hydration with locale support 107 self.create_event_view_with_locale(&event, locale).await.ok() 108 } else { 109 None 110 }; 111 112 let creator_handle = creator_handles.get(&event.did).cloned(); 113 let rsvp_count = rsvp_counts.get(&event.aturi).cloned(); 114 115 hydrated_events.push(HydratedEvent { 116 event, 117 event_view, 118 creator_handle, 119 rsvp_counts: rsvp_count, 120 }); 121 } 122 123 trace!("Hydrated {} events", hydrated_events.len()); 124 Ok(hydrated_events) 125 } 126 127 /// Fetch creator handles for a batch of events 128 async fn fetch_creator_handles( 129 &self, 130 events: &[Event], 131 ) -> Result<HashMap<String, Handle>, FilterError> { 132 let mut handles = HashMap::new(); 133 134 // Get unique DIDs 135 let dids: Vec<&String> = events.iter() 136 .map(|e| &e.did) 137 .collect::<std::collections::HashSet<_>>() 138 .into_iter() 139 .collect(); 140 141 // Batch fetch handles 142 for did in dids { 143 if let Ok(handle) = handle_for_did(&self.pool, did).await { 144 handles.insert(did.clone(), handle); 145 } 146 } 147 148 Ok(handles) 149 } 150 151 /// Fetch RSVP counts for a batch of events 152 async fn fetch_rsvp_counts( 153 &self, 154 events: &[Event], 155 ) -> Result<HashMap<String, RsvpCounts>, FilterError> { 156 let mut counts = HashMap::new(); 157 158 for event in events { 159 if let Ok(event_counts) = self.fetch_event_rsvp_counts(&event.aturi).await { 160 counts.insert(event.aturi.clone(), event_counts); 161 } 162 } 163 164 Ok(counts) 165 } 166 167 /// Fetch RSVP counts for a single event 168 async fn fetch_event_rsvp_counts(&self, event_aturi: &str) -> Result<RsvpCounts, FilterError> { 169 let going = sqlx::query_scalar::<_, i64>( 170 "SELECT COUNT(*) FROM rsvps WHERE event_aturi = $1 AND status = 'going'" 171 ) 172 .bind(event_aturi) 173 .fetch_one(&self.pool) 174 .await 175 .unwrap_or(0); 176 177 let interested = sqlx::query_scalar::<_, i64>( 178 "SELECT COUNT(*) FROM rsvps WHERE event_aturi = $1 AND status = 'interested'" 179 ) 180 .bind(event_aturi) 181 .fetch_one(&self.pool) 182 .await 183 .unwrap_or(0); 184 185 let not_going = sqlx::query_scalar::<_, i64>( 186 "SELECT COUNT(*) FROM rsvps WHERE event_aturi = $1 AND status = 'notgoing'" 187 ) 188 .bind(event_aturi) 189 .fetch_one(&self.pool) 190 .await 191 .unwrap_or(0); 192 193 let total = going + interested + not_going; 194 195 tracing::debug!("RSVP counts for event {}: going={}, interested={}, not_going={}, total={}", 196 event_aturi, going, interested, not_going, total); 197 198 Ok(RsvpCounts { 199 going, 200 interested, 201 not_going, 202 total, 203 }) 204 } 205 206 /// Create an EventView from an Event for RSVP hydration 207 async fn create_event_view(&self, event: &Event) -> Result<EventView, FilterError> { 208 // Extract event details from the record JSON 209 let event_details = crate::storage::event::extract_event_details(event); 210 211 // Parse the AT-URI to extract components 212 let parsed_uri = crate::atproto::uri::parse_aturi(&event.aturi) 213 .map_err(|_| FilterError::Hydration( 214 format!("Failed to parse AT-URI: {}", event.aturi) 215 ))?; 216 217 // Get organizer handle for URL generation 218 let organizer_handle = handle_for_did(&self.pool, &event.did).await 219 .map(|h| h.handle) 220 .unwrap_or_else(|_| event.did.clone()); 221 222 // Generate the correct URL format: /{handle_slug}/{event_rkey} 223 let handle_slug = crate::http::utils::slug_from_handle(&organizer_handle); 224 let rkey = parsed_uri.2; // The rkey is the third component of the parsed AT-URI 225 let site_url = format!("/{}/{}", handle_slug, rkey); 226 227 // Get organizer display name using the same logic as in handle_filter_events.rs 228 let organizer_display_name = { 229 // Only use the handle if it looks like a proper handle (contains a dot and doesn't start with "did:") 230 if organizer_handle.contains('.') && !organizer_handle.starts_with("did:") { 231 organizer_handle.clone() 232 } else { 233 // Fallback to a shortened DID if no proper handle is available 234 if event.did.len() > 20 { 235 format!("{}...", &event.did[..20]) 236 } else { 237 event.did.clone() 238 } 239 } 240 }; 241 242 let event_view = EventView { 243 site_url, 244 aturi: event.aturi.clone(), 245 cid: event.cid.clone(), 246 repository: parsed_uri.0, 247 collection: parsed_uri.1, 248 organizer_did: event.did.clone(), 249 organizer_display_name, 250 starts_at_machine: event_details.starts_at 251 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()), 252 starts_at_human: event_details.starts_at 253 .map(|dt| dt.format("%B %d, %Y at %l:%M %p").to_string()), 254 ends_at_machine: event_details.ends_at 255 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()), 256 ends_at_human: event_details.ends_at 257 .map(|dt| dt.format("%B %d, %Y at %l:%M %p").to_string()), 258 name: event_details.name.to_string(), 259 description: if event_details.description.is_empty() { 260 None 261 } else { 262 Some(crate::http::utils::truncate_text(&event_details.description, 500, None)) 263 }, 264 description_short: if event_details.description.is_empty() { 265 None 266 } else { 267 Some(crate::http::utils::truncate_text(&event_details.description, 200, None)) 268 }, 269 count_going: 0, 270 count_interested: 0, 271 count_notgoing: 0, 272 mode: event_details.mode.map(|m| m.to_string()), 273 status: event_details.status.map(|s| s.to_string()), 274 address_display: None, 275 location: None, // Alias for template compatibility 276 coordinates_lat: None, 277 coordinates_lng: None, 278 links: Vec::new(), 279 }; 280 281 Ok(event_view) 282 } 283 284 /// Create an EventView from an Event with locale-aware date formatting 285 async fn create_event_view_with_locale(&self, event: &Event, locale: Option<&str>) -> Result<EventView, FilterError> { 286 // Try to use locale-aware EventView creation if locale is provided 287 if let Some(locale_str) = locale { 288 if let Ok(language_id) = locale_str.parse::<LanguageIdentifier>() { 289 // Get organizer handle for locale-aware EventView creation 290 let organizer_handle = handle_for_did(&self.pool, &event.did).await.ok(); 291 292 // Use the locale-aware try_from_with_locale method 293 if let Ok(event_view) = EventView::try_from_with_locale( 294 (None, organizer_handle.as_ref(), event), 295 Some(&language_id) 296 ) { 297 return Ok(event_view); 298 } 299 } 300 } 301 302 // Fallback to non-locale-aware method if locale parsing fails or no locale provided 303 self.create_event_view(event).await 304 } 305} 306 307impl HydrationOptions { 308 /// Create basic hydration options (no extra data) 309 pub fn basic() -> Self { 310 Self::default() 311 } 312 313 /// Create full hydration options (all data included) 314 pub fn full() -> Self { 315 Self { 316 include_rsvp_counts: true, 317 include_creator_handles: true, 318 include_locations: true, 319 max_events: Some(100), // Reasonable limit for performance 320 } 321 } 322 323 /// Create options for list view (minimal data) 324 pub fn list_view() -> Self { 325 Self { 326 include_rsvp_counts: true, 327 include_creator_handles: true, 328 include_locations: false, 329 max_events: Some(50), 330 } 331 } 332 333 /// Create options for detailed view (full data) 334 pub fn detail_view() -> Self { 335 Self { 336 include_rsvp_counts: true, 337 include_creator_handles: true, 338 include_locations: true, 339 max_events: Some(20), 340 } 341 } 342} 343 344impl HydratedEvent { 345 /// Get the total RSVP count 346 pub fn total_rsvp_count(&self) -> i64 { 347 self.rsvp_counts.as_ref().map(|c| c.total).unwrap_or(0) 348 } 349 350 /// Get the creator handle or fallback to DID 351 pub fn creator_display_name(&self) -> String { 352 self.creator_handle 353 .as_ref() 354 .map(|h| h.handle.clone()) 355 .unwrap_or_else(|| self.event.did.clone()) 356 } 357 358 /// Check if the event has any RSVPs 359 pub fn has_rsvps(&self) -> bool { 360 self.total_rsvp_count() > 0 361 } 362} 363 364#[cfg(test)] 365mod tests { 366 use super::*; 367 368 #[test] 369 fn test_hydration_options() { 370 let basic = HydrationOptions::basic(); 371 assert!(!basic.include_rsvp_counts); 372 assert!(!basic.include_creator_handles); 373 374 let full = HydrationOptions::full(); 375 assert!(full.include_rsvp_counts); 376 assert!(full.include_creator_handles); 377 assert!(full.max_events.is_some()); 378 } 379}