// Event hydration service for filtering results // // Enriches filtered event data with additional information like // RSVP counts, creator handles, and related data needed for display. use std::collections::HashMap; use sqlx::PgPool; use tracing::{instrument, trace}; use unic_langid::LanguageIdentifier; use super::FilterError; use crate::http::event_view::EventView; use crate::storage::event::model::Event; use crate::storage::handle::{handle_for_did, model::Handle}; /// Service for hydrating event data with additional information #[derive(Debug, Clone)] pub struct EventHydrator { pool: PgPool, } /// Hydration options to control what data to include #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct HydrationOptions { /// Include RSVP counts for each event pub include_rsvp_counts: bool, /// Include creator handle information pub include_creator_handles: bool, /// Include location details pub include_locations: bool, /// Maximum number of events to hydrate (for performance) pub max_events: Option, } /// Hydrated event with additional data #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct HydratedEvent { pub event: Event, pub event_view: Option, pub creator_handle: Option, pub rsvp_counts: Option, } /// RSVP count information #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct RsvpCounts { pub going: i64, pub interested: i64, pub not_going: i64, pub total: i64, } impl EventHydrator { /// Create a new event hydrator pub fn new(pool: PgPool) -> Self { Self { pool } } /// Hydrate a list of events with additional data #[instrument(skip(self, events, options), fields(event_count = events.len()))] pub async fn hydrate_events( &self, events: Vec, options: &HydrationOptions, ) -> Result, FilterError> { self.hydrate_events_with_locale(events, options, None).await } /// Hydrate a list of events with additional data including locale-aware formatting #[instrument(skip(self, events, options, locale), fields(event_count = events.len()))] pub async fn hydrate_events_with_locale( &self, events: Vec, options: &HydrationOptions, locale: Option<&str>, ) -> Result, FilterError> { let mut hydrated_events = Vec::new(); // Limit the number of events to hydrate for performance let events_to_process = if let Some(max) = options.max_events { events.into_iter().take(max).collect() } else { events }; // Batch fetch creator handles if needed let creator_handles = if options.include_creator_handles { self.fetch_creator_handles(&events_to_process).await? } else { HashMap::new() }; // Batch fetch RSVP counts if needed let rsvp_counts = if options.include_rsvp_counts { self.fetch_rsvp_counts(&events_to_process).await? } else { HashMap::new() }; // Hydrate each event for event in events_to_process { let event_view = if options.include_rsvp_counts { // Convert to EventView for RSVP hydration with locale support self.create_event_view_with_locale(&event, locale).await.ok() } else { None }; let creator_handle = creator_handles.get(&event.did).cloned(); let rsvp_count = rsvp_counts.get(&event.aturi).cloned(); hydrated_events.push(HydratedEvent { event, event_view, creator_handle, rsvp_counts: rsvp_count, }); } trace!("Hydrated {} events", hydrated_events.len()); Ok(hydrated_events) } /// Fetch creator handles for a batch of events async fn fetch_creator_handles( &self, events: &[Event], ) -> Result, FilterError> { let mut handles = HashMap::new(); // Get unique DIDs let dids: Vec<&String> = events.iter() .map(|e| &e.did) .collect::>() .into_iter() .collect(); // Batch fetch handles for did in dids { if let Ok(handle) = handle_for_did(&self.pool, did).await { handles.insert(did.clone(), handle); } } Ok(handles) } /// Fetch RSVP counts for a batch of events async fn fetch_rsvp_counts( &self, events: &[Event], ) -> Result, FilterError> { let mut counts = HashMap::new(); for event in events { if let Ok(event_counts) = self.fetch_event_rsvp_counts(&event.aturi).await { counts.insert(event.aturi.clone(), event_counts); } } Ok(counts) } /// Fetch RSVP counts for a single event async fn fetch_event_rsvp_counts(&self, event_aturi: &str) -> Result { let going = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM rsvps WHERE event_aturi = $1 AND status = 'going'" ) .bind(event_aturi) .fetch_one(&self.pool) .await .unwrap_or(0); let interested = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM rsvps WHERE event_aturi = $1 AND status = 'interested'" ) .bind(event_aturi) .fetch_one(&self.pool) .await .unwrap_or(0); let not_going = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM rsvps WHERE event_aturi = $1 AND status = 'notgoing'" ) .bind(event_aturi) .fetch_one(&self.pool) .await .unwrap_or(0); let total = going + interested + not_going; Ok(RsvpCounts { going, interested, not_going, total, }) } /// Create an EventView from an Event for RSVP hydration async fn create_event_view(&self, event: &Event) -> Result { // Extract event details from the record JSON let event_details = crate::storage::event::extract_event_details(event); // Parse the AT-URI to extract components let parsed_uri = crate::atproto::uri::parse_aturi(&event.aturi) .map_err(|_| FilterError::Hydration( format!("Failed to parse AT-URI: {}", event.aturi) ))?; // Get organizer handle for URL generation let organizer_handle = handle_for_did(&self.pool, &event.did).await .map(|h| h.handle) .unwrap_or_else(|_| event.did.clone()); // Generate the correct URL format: /{handle_slug}/{event_rkey} let handle_slug = crate::http::utils::slug_from_handle(&organizer_handle); let rkey = parsed_uri.2; // The rkey is the third component of the parsed AT-URI let site_url = format!("/{}/{}", handle_slug, rkey); // Get organizer display name using the same logic as in handle_filter_events.rs let organizer_display_name = { // Only use the handle if it looks like a proper handle (contains a dot and doesn't start with "did:") if organizer_handle.contains('.') && !organizer_handle.starts_with("did:") { organizer_handle.clone() } else { // Fallback to a shortened DID if no proper handle is available if event.did.len() > 20 { format!("{}...", &event.did[..20]) } else { event.did.clone() } } }; let event_view = EventView { site_url, aturi: event.aturi.clone(), cid: event.cid.clone(), repository: parsed_uri.0, collection: parsed_uri.1, organizer_did: event.did.clone(), organizer_display_name, starts_at_machine: event_details.starts_at .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()), starts_at_human: event_details.starts_at .map(|dt| dt.format("%B %d, %Y at %l:%M %p").to_string()), ends_at_machine: event_details.ends_at .map(|dt| dt.format("%Y-%m-%dT%H:%M:%SZ").to_string()), ends_at_human: event_details.ends_at .map(|dt| dt.format("%B %d, %Y at %l:%M %p").to_string()), name: event_details.name.to_string(), description: if event_details.description.is_empty() { None } else { Some(crate::http::utils::truncate_text(&event_details.description, 500, None)) }, description_short: if event_details.description.is_empty() { None } else { Some(crate::http::utils::truncate_text(&event_details.description, 200, None)) }, count_going: 0, count_interested: 0, count_notgoing: 0, mode: event_details.mode.map(|m| m.to_string()), status: event_details.status.map(|s| s.to_string()), address_display: None, links: Vec::new(), }; Ok(event_view) } /// Create an EventView from an Event with locale-aware date formatting async fn create_event_view_with_locale(&self, event: &Event, locale: Option<&str>) -> Result { // Try to use locale-aware EventView creation if locale is provided if let Some(locale_str) = locale { if let Ok(language_id) = locale_str.parse::() { // Get organizer handle for locale-aware EventView creation let organizer_handle = handle_for_did(&self.pool, &event.did).await.ok(); // Use the locale-aware try_from_with_locale method if let Ok(event_view) = EventView::try_from_with_locale( (None, organizer_handle.as_ref(), event), Some(&language_id) ) { return Ok(event_view); } } } // Fallback to non-locale-aware method if locale parsing fails or no locale provided self.create_event_view(event).await } } impl HydrationOptions { /// Create basic hydration options (no extra data) pub fn basic() -> Self { Self::default() } /// Create full hydration options (all data included) pub fn full() -> Self { Self { include_rsvp_counts: true, include_creator_handles: true, include_locations: true, max_events: Some(100), // Reasonable limit for performance } } /// Create options for list view (minimal data) pub fn list_view() -> Self { Self { include_rsvp_counts: true, include_creator_handles: true, include_locations: false, max_events: Some(50), } } /// Create options for detailed view (full data) pub fn detail_view() -> Self { Self { include_rsvp_counts: true, include_creator_handles: true, include_locations: true, max_events: Some(20), } } } impl HydratedEvent { /// Get the total RSVP count pub fn total_rsvp_count(&self) -> i64 { self.rsvp_counts.as_ref().map(|c| c.total).unwrap_or(0) } /// Get the creator handle or fallback to DID pub fn creator_display_name(&self) -> String { self.creator_handle .as_ref() .map(|h| h.handle.clone()) .unwrap_or_else(|| self.event.did.clone()) } /// Check if the event has any RSVPs pub fn has_rsvps(&self) -> bool { self.total_rsvp_count() > 0 } } #[cfg(test)] mod tests { use super::*; #[test] fn test_hydration_options() { let basic = HydrationOptions::basic(); assert!(!basic.include_rsvp_counts); assert!(!basic.include_creator_handles); let full = HydrationOptions::full(); assert!(full.include_rsvp_counts); assert!(full.include_creator_handles); assert!(full.max_events.is_some()); } }