Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t
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}