forked from
smokesignal.events/smokesignal
i18n+filtering fork - fluent-templates v2
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}