i18n+filtering fork - fluent-templates v2
at main 373 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 Ok(RsvpCounts { 196 going, 197 interested, 198 not_going, 199 total, 200 }) 201 } 202 203 /// Create an EventView from an Event for RSVP hydration 204 async fn create_event_view(&self, event: &Event) -> Result<EventView, FilterError> { 205 // Extract event details from the record JSON 206 let event_details = crate::storage::event::extract_event_details(event); 207 208 // Parse the AT-URI to extract components 209 let parsed_uri = crate::atproto::uri::parse_aturi(&event.aturi) 210 .map_err(|_| FilterError::Hydration( 211 format!("Failed to parse AT-URI: {}", event.aturi) 212 ))?; 213 214 // Get organizer handle for URL generation 215 let organizer_handle = handle_for_did(&self.pool, &event.did).await 216 .map(|h| h.handle) 217 .unwrap_or_else(|_| event.did.clone()); 218 219 // Generate the correct URL format: /{handle_slug}/{event_rkey} 220 let handle_slug = crate::http::utils::slug_from_handle(&organizer_handle); 221 let rkey = parsed_uri.2; // The rkey is the third component of the parsed AT-URI 222 let site_url = format!("/{}/{}", handle_slug, rkey); 223 224 // Get organizer display name using the same logic as in handle_filter_events.rs 225 let organizer_display_name = { 226 // Only use the handle if it looks like a proper handle (contains a dot and doesn't start with "did:") 227 if organizer_handle.contains('.') && !organizer_handle.starts_with("did:") { 228 organizer_handle.clone() 229 } else { 230 // Fallback to a shortened DID if no proper handle is available 231 if event.did.len() > 20 { 232 format!("{}...", &event.did[..20]) 233 } else { 234 event.did.clone() 235 } 236 } 237 }; 238 239 let event_view = EventView { 240 site_url, 241 aturi: event.aturi.clone(), 242 cid: event.cid.clone(), 243 repository: parsed_uri.0, 244 collection: parsed_uri.1, 245 organizer_did: event.did.clone(), 246 organizer_display_name, 247 starts_at_machine: event_details.starts_at 248 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()), 249 starts_at_human: event_details.starts_at 250 .map(|dt| dt.format("%B %d, %Y at %l:%M %p").to_string()), 251 ends_at_machine: event_details.ends_at 252 .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()), 253 ends_at_human: event_details.ends_at 254 .map(|dt| dt.format("%B %d, %Y at %l:%M %p").to_string()), 255 name: event_details.name.to_string(), 256 description: if event_details.description.is_empty() { 257 None 258 } else { 259 Some(crate::http::utils::truncate_text(&event_details.description, 500, None)) 260 }, 261 description_short: if event_details.description.is_empty() { 262 None 263 } else { 264 Some(crate::http::utils::truncate_text(&event_details.description, 200, None)) 265 }, 266 count_going: 0, 267 count_interested: 0, 268 count_notgoing: 0, 269 mode: event_details.mode.map(|m| m.to_string()), 270 status: event_details.status.map(|s| s.to_string()), 271 address_display: None, 272 links: Vec::new(), 273 }; 274 275 Ok(event_view) 276 } 277 278 /// Create an EventView from an Event with locale-aware date formatting 279 async fn create_event_view_with_locale(&self, event: &Event, locale: Option<&str>) -> Result<EventView, FilterError> { 280 // Try to use locale-aware EventView creation if locale is provided 281 if let Some(locale_str) = locale { 282 if let Ok(language_id) = locale_str.parse::<LanguageIdentifier>() { 283 // Get organizer handle for locale-aware EventView creation 284 let organizer_handle = handle_for_did(&self.pool, &event.did).await.ok(); 285 286 // Use the locale-aware try_from_with_locale method 287 if let Ok(event_view) = EventView::try_from_with_locale( 288 (None, organizer_handle.as_ref(), event), 289 Some(&language_id) 290 ) { 291 return Ok(event_view); 292 } 293 } 294 } 295 296 // Fallback to non-locale-aware method if locale parsing fails or no locale provided 297 self.create_event_view(event).await 298 } 299} 300 301impl HydrationOptions { 302 /// Create basic hydration options (no extra data) 303 pub fn basic() -> Self { 304 Self::default() 305 } 306 307 /// Create full hydration options (all data included) 308 pub fn full() -> Self { 309 Self { 310 include_rsvp_counts: true, 311 include_creator_handles: true, 312 include_locations: true, 313 max_events: Some(100), // Reasonable limit for performance 314 } 315 } 316 317 /// Create options for list view (minimal data) 318 pub fn list_view() -> Self { 319 Self { 320 include_rsvp_counts: true, 321 include_creator_handles: true, 322 include_locations: false, 323 max_events: Some(50), 324 } 325 } 326 327 /// Create options for detailed view (full data) 328 pub fn detail_view() -> Self { 329 Self { 330 include_rsvp_counts: true, 331 include_creator_handles: true, 332 include_locations: true, 333 max_events: Some(20), 334 } 335 } 336} 337 338impl HydratedEvent { 339 /// Get the total RSVP count 340 pub fn total_rsvp_count(&self) -> i64 { 341 self.rsvp_counts.as_ref().map(|c| c.total).unwrap_or(0) 342 } 343 344 /// Get the creator handle or fallback to DID 345 pub fn creator_display_name(&self) -> String { 346 self.creator_handle 347 .as_ref() 348 .map(|h| h.handle.clone()) 349 .unwrap_or_else(|| self.event.did.clone()) 350 } 351 352 /// Check if the event has any RSVPs 353 pub fn has_rsvps(&self) -> bool { 354 self.total_rsvp_count() > 0 355 } 356} 357 358#[cfg(test)] 359mod tests { 360 use super::*; 361 362 #[test] 363 fn test_hydration_options() { 364 let basic = HydrationOptions::basic(); 365 assert!(!basic.include_rsvp_counts); 366 assert!(!basic.include_creator_handles); 367 368 let full = HydrationOptions::full(); 369 assert!(full.include_rsvp_counts); 370 assert!(full.include_creator_handles); 371 assert!(full.max_events.is_some()); 372 } 373}