Heavily customized version of smokesignal - https://whtwnd.com/kayrozen.com/3lpwe4ymowg2t
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Ajout de la documentation pour les facettes

+1500 -710
+1500
playbooks/facets.md
··· 1 + # Comprehensive Implementation Plan: Faceted Filtering with Themed Pages 2 + 3 + ## Goal 4 + Implement comprehensive faceted filtering on all fields from the community lexicon calendar event schema with performance optimization through caching, plus reusable themed pages with predefined filter configurations. 5 + 6 + ## Data Structure Overview 7 + Filterable fields from the lexicon: 8 + - `name` (String) - Text search 9 + - `description` (String) - Text search 10 + - `starts_at` / `ends_at` (DateTime) - Range filtering 11 + - `mode` (Enum: InPerson, Virtual, Hybrid) - Multi-select 12 + - `status` (Enum: Scheduled, Rescheduled, Cancelled, Postponed, Planned) - Multi-select 13 + - `locations` (Vec<EventLocation>) - Location-based filtering 14 + - `uris` (Vec<EventLink>) - URI type filtering 15 + - `extra` (HashMap) - Custom field filtering 16 + 17 + ## Implementation Plan 18 + 19 + ### Phase 1: Core Data Structures 20 + 21 + ````rust 22 + use chrono::{DateTime, Utc}; 23 + use serde::{Deserialize, Serialize}; 24 + use std::collections::HashMap; 25 + 26 + #[derive(Debug, Deserialize, Serialize, Clone, Default)] 27 + pub struct EventFilters { 28 + // Text filters 29 + pub name: Option<Vec<String>>, 30 + pub description: Option<Vec<String>>, 31 + 32 + // Enum filters 33 + pub status: Option<Vec<String>>, 34 + pub mode: Option<Vec<String>>, 35 + 36 + // Date range filters 37 + pub starts_after: Option<DateTime<Utc>>, 38 + pub starts_before: Option<DateTime<Utc>>, 39 + pub ends_after: Option<DateTime<Utc>>, 40 + pub ends_before: Option<DateTime<Utc>>, 41 + 42 + // Location filters 43 + pub location_type: Option<Vec<String>>, 44 + pub location_name: Option<Vec<String>>, 45 + 46 + // URI/Link filters 47 + pub uri_type: Option<Vec<String>>, 48 + 49 + // Extra field filters 50 + pub extra_keys: Option<Vec<String>>, 51 + pub extra_values: Option<HashMap<String, Vec<String>>>, 52 + 53 + // Existing filters 54 + pub organizer_did: Option<String>, 55 + pub sort_by: Option<String>, 56 + pub page: Option<i64>, 57 + pub page_size: Option<i64>, 58 + } 59 + 60 + #[derive(Debug, Deserialize, Serialize, Clone)] 61 + pub struct ThemedPageConfig { 62 + pub id: String, 63 + pub title: String, 64 + pub description: Option<String>, 65 + pub base_filters: EventFilters, 66 + pub template_name: Option<String>, 67 + pub css_class: Option<String>, 68 + pub enabled: bool, 69 + pub seo_keywords: Option<Vec<String>>, 70 + pub cache_ttl: Option<u64>, // Custom cache TTL for this theme 71 + } 72 + 73 + #[derive(Debug, Deserialize, Serialize, Clone)] 74 + pub struct ThemedPagesConfig { 75 + pub pages: Vec<ThemedPageConfig>, 76 + } 77 + 78 + impl EventFilters { 79 + /// Merge user filters with theme base filters 80 + /// Base filters act as constraints, user filters refine within those constraints 81 + pub fn merge_with_base(mut self, base: &EventFilters) -> Self { 82 + // Text filters: combine if both exist 83 + if self.name.is_none() && base.name.is_some() { 84 + self.name = base.name.clone(); 85 + } else if let (Some(user_names), Some(base_names)) = (&mut self.name, &base.name) { 86 + user_names.extend(base_names.clone()); 87 + } 88 + 89 + if self.description.is_none() && base.description.is_some() { 90 + self.description = base.description.clone(); 91 + } else if let (Some(user_desc), Some(base_desc)) = (&mut self.description, &base.description) { 92 + user_desc.extend(base_desc.clone()); 93 + } 94 + 95 + // Enum filters: intersect to ensure user selection stays within theme constraints 96 + self.status = self.intersect_string_filters(self.status, base.status.clone()); 97 + self.mode = self.intersect_string_filters(self.mode, base.mode.clone()); 98 + self.location_type = self.intersect_string_filters(self.location_type, base.location_type.clone()); 99 + self.location_name = self.intersect_string_filters(self.location_name, base.location_name.clone()); 100 + self.uri_type = self.intersect_string_filters(self.uri_type, base.uri_type.clone()); 101 + 102 + // Date filters: use more restrictive range 103 + if let Some(base_after) = base.starts_after { 104 + self.starts_after = Some(match self.starts_after { 105 + Some(user_after) => user_after.max(base_after), 106 + None => base_after, 107 + }); 108 + } 109 + 110 + if let Some(base_before) = base.starts_before { 111 + self.starts_before = Some(match self.starts_before { 112 + Some(user_before) => user_before.min(base_before), 113 + None => base_before, 114 + }); 115 + } 116 + 117 + // Extra fields: combine 118 + if let Some(base_keys) = &base.extra_keys { 119 + match &mut self.extra_keys { 120 + Some(user_keys) => user_keys.extend(base_keys.clone()), 121 + None => self.extra_keys = base.extra_keys.clone(), 122 + } 123 + } 124 + 125 + if let Some(base_values) = &base.extra_values { 126 + match &mut self.extra_values { 127 + Some(user_values) => { 128 + for (key, values) in base_values { 129 + user_values.entry(key.clone()).or_insert_with(Vec::new).extend(values.clone()); 130 + } 131 + } 132 + None => self.extra_values = base.extra_values.clone(), 133 + } 134 + } 135 + 136 + // Use base values if user didn't specify 137 + if self.organizer_did.is_none() { 138 + self.organizer_did = base.organizer_did.clone(); 139 + } 140 + if self.sort_by.is_none() { 141 + self.sort_by = base.sort_by.clone(); 142 + } 143 + 144 + self 145 + } 146 + 147 + fn intersect_string_filters(&self, user: Option<Vec<String>>, base: Option<Vec<String>>) -> Option<Vec<String>> { 148 + match (user, base) { 149 + (Some(user_vec), Some(base_vec)) => { 150 + let intersection: Vec<String> = user_vec.into_iter() 151 + .filter(|item| base_vec.contains(item)) 152 + .collect(); 153 + if intersection.is_empty() { Some(base_vec) } else { Some(intersection) } 154 + } 155 + (None, Some(base_vec)) => Some(base_vec), 156 + (Some(user_vec), None) => Some(user_vec), 157 + (None, None) => None, 158 + } 159 + } 160 + } 161 + ```` 162 + 163 + ### Phase 2: Hydration and Caching Infrastructure 164 + 165 + ````rust 166 + use crate::community_lexicon::{Event, EventLocation, EventLink, Mode, Status}; 167 + use chrono::{DateTime, Utc}; 168 + use redis::AsyncCommands; 169 + use serde::{Deserialize, Serialize}; 170 + use std::collections::HashMap; 171 + use thiserror::Error; 172 + 173 + #[derive(Debug, Serialize, Deserialize, Clone)] 174 + pub struct HydratedEvent { 175 + pub name: String, 176 + pub description: String, 177 + pub starts_at: Option<DateTime<Utc>>, 178 + pub ends_at: Option<DateTime<Utc>>, 179 + pub mode: Option<Mode>, 180 + pub status: Option<Status>, 181 + pub locations: Vec<EventLocation>, 182 + pub uris: Vec<EventLink>, 183 + pub extra: HashMap<String, serde_json::Value>, 184 + } 185 + 186 + #[derive(Debug, Error)] 187 + pub enum CacheError { 188 + #[error("Redis error: {0}")] 189 + Redis(#[from] redis::RedisError), 190 + #[error("Serialization error: {0}")] 191 + Serialization(#[from] serde_json::Error), 192 + #[error("Connection pool error: {0}")] 193 + ConnectionPool(#[from] deadpool::managed::PoolError), 194 + #[error("AT Protocol error: {0}")] 195 + AtProtocol(String), 196 + } 197 + 198 + pub struct EventHydrationCache { 199 + redis_pool: deadpool_redis::Pool, 200 + default_ttl: u64, 201 + } 202 + 203 + impl EventHydrationCache { 204 + pub fn new(redis_pool: deadpool_redis::Pool, default_ttl: u64) -> Self { 205 + Self { redis_pool, default_ttl } 206 + } 207 + 208 + /// Get hydrated event from cache or fetch from AT Protocol 209 + pub async fn get_hydrated_event(&self, aturi: &str, custom_ttl: Option<u64>) -> Result<HydratedEvent, CacheError> { 210 + let cache_key = format!("event_hydrated:{}", aturi); 211 + 212 + // Try cache first 213 + if let Some(cached) = self.get_from_cache(&cache_key).await? { 214 + return Ok(cached); 215 + } 216 + 217 + // Fetch from AT Protocol 218 + let hydrated = self.hydrate_from_atproto(aturi).await?; 219 + 220 + // Store in cache 221 + let ttl = custom_ttl.unwrap_or(self.default_ttl); 222 + self.store_in_cache(&cache_key, &hydrated, ttl).await?; 223 + 224 + Ok(hydrated) 225 + } 226 + 227 + /// Batch hydrate multiple events 228 + pub async fn batch_hydrate_events(&self, aturis: &[String]) -> Result<HashMap<String, HydratedEvent>, CacheError> { 229 + let mut results = HashMap::new(); 230 + let mut to_fetch = Vec::new(); 231 + 232 + // Check cache for all events 233 + for aturi in aturis { 234 + let cache_key = format!("event_hydrated:{}", aturi); 235 + if let Some(cached) = self.get_from_cache(&cache_key).await? { 236 + results.insert(aturi.clone(), cached); 237 + } else { 238 + to_fetch.push(aturi.clone()); 239 + } 240 + } 241 + 242 + // Fetch missing events from AT Protocol (in parallel) 243 + let fetch_futures: Vec<_> = to_fetch.iter() 244 + .map(|aturi| self.hydrate_from_atproto(aturi)) 245 + .collect(); 246 + 247 + let fetch_results = futures::future::join_all(fetch_futures).await; 248 + 249 + // Store fetched results and add to return map 250 + for (aturi, result) in to_fetch.iter().zip(fetch_results) { 251 + match result { 252 + Ok(hydrated) => { 253 + let cache_key = format!("event_hydrated:{}", aturi); 254 + let _ = self.store_in_cache(&cache_key, &hydrated, self.default_ttl).await; 255 + results.insert(aturi.clone(), hydrated); 256 + } 257 + Err(e) => log::warn!("Failed to hydrate event {}: {}", aturi, e), 258 + } 259 + } 260 + 261 + Ok(results) 262 + } 263 + 264 + async fn get_from_cache(&self, cache_key: &str) -> Result<Option<HydratedEvent>, CacheError> { 265 + let mut conn = self.redis_pool.get().await?; 266 + let cached_data: Option<String> = conn.get(cache_key).await?; 267 + 268 + match cached_data { 269 + Some(data) => { 270 + let hydrated_event: HydratedEvent = serde_json::from_str(&data)?; 271 + Ok(Some(hydrated_event)) 272 + } 273 + None => Ok(None), 274 + } 275 + } 276 + 277 + async fn store_in_cache(&self, cache_key: &str, event: &HydratedEvent, ttl: u64) -> Result<(), CacheError> { 278 + let mut conn = self.redis_pool.get().await?; 279 + let data = serde_json::to_string(event)?; 280 + conn.set(cache_key, data).await?; 281 + conn.expire(cache_key, ttl as i64).await?; 282 + Ok(()) 283 + } 284 + 285 + async fn hydrate_from_atproto(&self, aturi: &str) -> Result<HydratedEvent, CacheError> { 286 + // Mock implementation - replace with actual AT Protocol client 287 + let event_data = fetch_event_from_atproto(aturi).await 288 + .map_err(|e| CacheError::AtProtocol(e.to_string()))?; 289 + 290 + match event_data { 291 + Event::Current { 292 + name, description, starts_at, ends_at, mode, status, locations, uris, extra 293 + } => Ok(HydratedEvent { 294 + name, description, starts_at, ends_at, mode, status, locations, uris, extra 295 + }) 296 + } 297 + } 298 + 299 + /// Invalidate cache for specific event 300 + pub async fn invalidate_event(&self, aturi: &str) -> Result<(), CacheError> { 301 + let cache_key = format!("event_hydrated:{}", aturi); 302 + let mut conn = self.redis_pool.get().await?; 303 + conn.del(&cache_key).await?; 304 + Ok(()) 305 + } 306 + } 307 + 308 + // Mock function - implement with actual AT Protocol client 309 + async fn fetch_event_from_atproto(aturi: &str) -> Result<Event, anyhow::Error> { 310 + // Implementation depends on your AT Protocol client 311 + unimplemented!("Implement AT Protocol event fetching") 312 + } 313 + ```` 314 + 315 + ### Phase 3: Filtering Engine 316 + 317 + ````rust 318 + use crate::cache::event_hydration::{EventHydrationCache, HydratedEvent}; 319 + use crate::http::filters::EventFilters; 320 + use crate::storage::{EventWithRole, StoragePool}; 321 + use anyhow::Result; 322 + 323 + pub struct EventFilteringService { 324 + hydration_cache: EventHydrationCache, 325 + } 326 + 327 + impl EventFilteringService { 328 + pub fn new(hydration_cache: EventHydrationCache) -> Self { 329 + Self { hydration_cache } 330 + } 331 + 332 + /// Apply comprehensive filtering with caching 333 + pub async fn filter_events( 334 + &self, 335 + storage_pool: &StoragePool, 336 + filters: &EventFilters, 337 + ) -> Result<(i64, Vec<EventWithRole>)> { 338 + // Step 1: Database pre-filtering (for efficiently filterable fields) 339 + let db_events = self.get_events_from_db(storage_pool, filters).await?; 340 + 341 + // Step 2: Extract AT-URIs for hydration 342 + let aturis: Vec<String> = db_events.iter() 343 + .map(|e| e.event.aturi.clone()) 344 + .collect(); 345 + 346 + // Step 3: Batch hydrate events 347 + let hydrated_map = self.hydration_cache.batch_hydrate_events(&aturis).await?; 348 + 349 + // Step 4: Apply comprehensive filters 350 + let filtered_events = self.apply_comprehensive_filters(db_events, &hydrated_map, filters); 351 + 352 + // Step 5: Apply sorting and pagination 353 + let sorted_events = self.apply_sorting_and_pagination(filtered_events, filters); 354 + 355 + let total_count = sorted_events.len() as i64; 356 + Ok((total_count, sorted_events)) 357 + } 358 + 359 + async fn get_events_from_db( 360 + &self, 361 + pool: &StoragePool, 362 + filters: &EventFilters, 363 + ) -> Result<Vec<EventWithRole>> { 364 + // Build database query with efficiently filterable fields only 365 + // Organizer DID, date ranges, basic metadata 366 + // Don't filter on fields that require AT Protocol data 367 + 368 + // Implementation would call your existing database query function 369 + // but modified to exclude AT Protocol dependent filters 370 + unimplemented!("Implement database pre-filtering") 371 + } 372 + 373 + fn apply_comprehensive_filters( 374 + &self, 375 + events: Vec<EventWithRole>, 376 + hydrated_map: &std::collections::HashMap<String, HydratedEvent>, 377 + filters: &EventFilters, 378 + ) -> Vec<EventWithRole> { 379 + events.into_iter() 380 + .filter(|event_with_role| { 381 + if let Some(hydrated) = hydrated_map.get(&event_with_role.event.aturi) { 382 + self.should_include_event(hydrated, filters) 383 + } else { 384 + // If hydration failed, include in results but log warning 385 + log::warn!("Failed to hydrate event: {}", event_with_role.event.aturi); 386 + true 387 + } 388 + }) 389 + .collect() 390 + } 391 + 392 + fn should_include_event(&self, event: &HydratedEvent, filters: &EventFilters) -> bool { 393 + // Text filters (case-insensitive partial matching) 394 + if let Some(name_filters) = &filters.name { 395 + if !name_filters.is_empty() { 396 + let matches = name_filters.iter().any(|filter| { 397 + event.name.to_lowercase().contains(&filter.to_lowercase()) 398 + }); 399 + if !matches { return false; } 400 + } 401 + } 402 + 403 + if let Some(desc_filters) = &filters.description { 404 + if !desc_filters.is_empty() { 405 + let matches = desc_filters.iter().any(|filter| { 406 + event.description.to_lowercase().contains(&filter.to_lowercase()) 407 + }); 408 + if !matches { return false; } 409 + } 410 + } 411 + 412 + // Status enum filter 413 + if let Some(status_filters) = &filters.status { 414 + if !status_filters.is_empty() { 415 + if let Some(event_status) = &event.status { 416 + let status_str = match event_status { 417 + Status::Scheduled => "scheduled", 418 + Status::Rescheduled => "rescheduled", 419 + Status::Cancelled => "cancelled", 420 + Status::Postponed => "postponed", 421 + Status::Planned => "planned", 422 + }; 423 + if !status_filters.contains(&status_str.to_string()) { 424 + return false; 425 + } 426 + } else { 427 + return false; // No status but filter requires one 428 + } 429 + } 430 + } 431 + 432 + // Mode enum filter 433 + if let Some(mode_filters) = &filters.mode { 434 + if !mode_filters.is_empty() { 435 + if let Some(event_mode) = &event.mode { 436 + let mode_str = match event_mode { 437 + Mode::InPerson => "inperson", 438 + Mode::Virtual => "virtual", 439 + Mode::Hybrid => "hybrid", 440 + }; 441 + if !mode_filters.contains(&mode_str.to_string()) { 442 + return false; 443 + } 444 + } else { 445 + return false; // No mode but filter requires one 446 + } 447 + } 448 + } 449 + 450 + // Date range filters 451 + if let Some(starts_after) = filters.starts_after { 452 + if let Some(event_starts) = event.starts_at { 453 + if event_starts <= starts_after { 454 + return false; 455 + } 456 + } else { 457 + return false; // No start time but filter requires one 458 + } 459 + } 460 + 461 + if let Some(starts_before) = filters.starts_before { 462 + if let Some(event_starts) = event.starts_at { 463 + if event_starts >= starts_before { 464 + return false; 465 + } 466 + } 467 + } 468 + 469 + if let Some(ends_after) = filters.ends_after { 470 + if let Some(event_ends) = event.ends_at { 471 + if event_ends <= ends_after { 472 + return false; 473 + } 474 + } 475 + } 476 + 477 + if let Some(ends_before) = filters.ends_before { 478 + if let Some(event_ends) = event.ends_at { 479 + if event_ends >= ends_before { 480 + return false; 481 + } 482 + } 483 + } 484 + 485 + // Location filters 486 + if let Some(location_name_filters) = &filters.location_name { 487 + if !location_name_filters.is_empty() { 488 + let has_matching_location = event.locations.iter().any(|loc| { 489 + location_name_filters.iter().any(|filter| { 490 + loc.name.to_lowercase().contains(&filter.to_lowercase()) 491 + }) 492 + }); 493 + if !has_matching_location { 494 + return false; 495 + } 496 + } 497 + } 498 + 499 + if let Some(location_type_filters) = &filters.location_type { 500 + if !location_type_filters.is_empty() { 501 + let has_matching_type = event.locations.iter().any(|loc| { 502 + location_type_filters.iter().any(|filter| { 503 + // Match against location properties 504 + loc.name.to_lowercase().contains(&filter.to_lowercase()) 505 + }) 506 + }); 507 + if !has_matching_type { 508 + return false; 509 + } 510 + } 511 + } 512 + 513 + // URI filters 514 + if let Some(uri_type_filters) = &filters.uri_type { 515 + if !uri_type_filters.is_empty() { 516 + let has_matching_uri = event.uris.iter().any(|uri| { 517 + uri_type_filters.iter().any(|filter| { 518 + uri.uri.to_lowercase().contains(&filter.to_lowercase()) 519 + }) 520 + }); 521 + if !has_matching_uri { 522 + return false; 523 + } 524 + } 525 + } 526 + 527 + // Extra fields filters 528 + if let Some(extra_key_filters) = &filters.extra_keys { 529 + if !extra_key_filters.is_empty() { 530 + let has_matching_key = extra_key_filters.iter().any(|key| { 531 + event.extra.contains_key(key) 532 + }); 533 + if !has_matching_key { 534 + return false; 535 + } 536 + } 537 + } 538 + 539 + if let Some(extra_value_filters) = &filters.extra_values { 540 + for (key, values) in extra_value_filters { 541 + if let Some(event_value) = event.extra.get(key) { 542 + let event_value_str = event_value.to_string().to_lowercase(); 543 + if !values.iter().any(|v| event_value_str.contains(&v.to_lowercase())) { 544 + return false; 545 + } 546 + } else { 547 + return false; // Required key not present 548 + } 549 + } 550 + } 551 + 552 + true 553 + } 554 + 555 + fn apply_sorting_and_pagination( 556 + &self, 557 + mut events: Vec<EventWithRole>, 558 + filters: &EventFilters, 559 + ) -> Vec<EventWithRole> { 560 + // Apply sorting 561 + if let Some(sort_by) = &filters.sort_by { 562 + match sort_by.as_str() { 563 + "starts_at" => { 564 + events.sort_by(|a, b| { 565 + // Would need to access hydrated data for sorting 566 + // For now, use database field if available 567 + std::cmp::Ordering::Equal 568 + }); 569 + } 570 + "name" => { 571 + events.sort_by(|a, b| { 572 + // Similar challenge - would need hydrated data 573 + std::cmp::Ordering::Equal 574 + }); 575 + } 576 + _ => {} // Default order 577 + } 578 + } 579 + 580 + // Apply pagination 581 + let page = filters.page.unwrap_or(1); 582 + let page_size = filters.page_size.unwrap_or(20); 583 + let start_idx = ((page - 1) * page_size) as usize; 584 + let end_idx = (start_idx + page_size as usize).min(events.len()); 585 + 586 + if start_idx < events.len() { 587 + events[start_idx..end_idx].to_vec() 588 + } else { 589 + Vec::new() 590 + } 591 + } 592 + } 593 + ```` 594 + 595 + ### Phase 4: Themed Pages Configuration 596 + 597 + ````rust 598 + use crate::http::filters::{EventFilters, ThemedPageConfig, ThemedPagesConfig}; 599 + use chrono::{Duration, Utc}; 600 + use std::collections::HashMap; 601 + 602 + impl Default for ThemedPagesConfig { 603 + fn default() -> Self { 604 + Self { 605 + pages: vec![ 606 + // Virtual Events Theme 607 + ThemedPageConfig { 608 + id: "virtual-events".to_string(), 609 + title: "Virtual Events".to_string(), 610 + description: Some("Online events you can attend from anywhere".to_string()), 611 + base_filters: EventFilters { 612 + mode: Some(vec!["virtual".to_string()]), 613 + status: Some(vec!["scheduled".to_string(), "planned".to_string()]), 614 + starts_after: Some(Utc::now()), 615 + sort_by: Some("starts_at".to_string()), 616 + ..Default::default() 617 + }, 618 + template_name: Some("virtual_events".to_string()), 619 + css_class: Some("virtual-theme".to_string()), 620 + enabled: true, 621 + seo_keywords: Some(vec!["virtual".to_string(), "online".to_string(), "remote".to_string()]), 622 + cache_ttl: Some(1800), // 30 minutes for dynamic content 623 + }, 624 + 625 + // Local Meetups Theme 626 + ThemedPageConfig { 627 + id: "local-meetups".to_string(), 628 + title: "Local Meetups".to_string(), 629 + description: Some("In-person community gatherings in your area".to_string()), 630 + base_filters: EventFilters { 631 + mode: Some(vec!["inperson".to_string()]), 632 + extra_keys: Some(vec!["meetup".to_string(), "community".to_string(), "local".to_string()]), 633 + status: Some(vec!["scheduled".to_string()]), 634 + starts_after: Some(Utc::now()), 635 + sort_by: Some("starts_at".to_string()), 636 + ..Default::default() 637 + }, 638 + template_name: Some("local_meetups".to_string()), 639 + css_class: Some("meetup-theme".to_string()), 640 + enabled: true, 641 + seo_keywords: Some(vec!["meetup".to_string(), "local".to_string(), "community".to_string()]), 642 + cache_ttl: Some(3600), // 1 hour 643 + }, 644 + 645 + // Conferences Theme 646 + ThemedPageConfig { 647 + id: "conferences".to_string(), 648 + title: "Conferences & Large Events".to_string(), 649 + description: Some("Major conferences, summits, and large-scale professional events".to_string()), 650 + base_filters: EventFilters { 651 + extra_keys: Some(vec!["conference".to_string(), "summit".to_string(), "convention".to_string()]), 652 + uri_type: Some(vec!["tickets".to_string(), "registration".to_string()]), 653 + status: Some(vec!["scheduled".to_string(), "planned".to_string()]), 654 + sort_by: Some("starts_at".to_string()), 655 + ..Default::default() 656 + }, 657 + template_name: Some("conferences".to_string()), 658 + css_class: Some("conference-theme".to_string()), 659 + enabled: true, 660 + seo_keywords: Some(vec!["conference".to_string(), "summit".to_string(), "professional".to_string()]), 661 + cache_ttl: Some(7200), // 2 hours - conferences change less frequently 662 + }, 663 + 664 + // This Week Theme 665 + ThemedPageConfig { 666 + id: "this-week".to_string(), 667 + title: "This Week's Events".to_string(), 668 + description: Some("All events happening in the next 7 days".to_string()), 669 + base_filters: EventFilters { 670 + starts_after: Some(Utc::now()), 671 + starts_before: Some(Utc::now() + Duration::days(7)), 672 + status: Some(vec!["scheduled".to_string()]), 673 + sort_by: Some("starts_at".to_string()), 674 + ..Default::default() 675 + }, 676 + template_name: Some("this_week".to_string()), 677 + css_class: Some("this-week-theme".to_string()), 678 + enabled: true, 679 + seo_keywords: Some(vec!["this week".to_string(), "upcoming".to_string(), "soon".to_string()]), 680 + cache_ttl: Some(900), // 15 minutes - very dynamic content 681 + }, 682 + 683 + // Free Events Theme 684 + ThemedPageConfig { 685 + id: "free-events".to_string(), 686 + title: "Free Events".to_string(), 687 + description: Some("No-cost events open to everyone".to_string()), 688 + base_filters: EventFilters { 689 + extra_values: Some(HashMap::from([ 690 + ("cost".to_string(), vec!["free".to_string(), "0".to_string(), "$0".to_string()]), 691 + ("price".to_string(), vec!["free".to_string(), "0".to_string(), "$0".to_string()]), 692 + ("admission".to_string(), vec!["free".to_string(), "no cost".to_string()]), 693 + ])), 694 + status: Some(vec!["scheduled".to_string(), "planned".to_string()]), 695 + starts_after: Some(Utc::now()), 696 + sort_by: Some("starts_at".to_string()), 697 + ..Default::default() 698 + }, 699 + template_name: Some("free_events".to_string()), 700 + css_class: Some("free-theme".to_string()), 701 + enabled: true, 702 + seo_keywords: Some(vec!["free".to_string(), "no cost".to_string(), "complimentary".to_string()]), 703 + cache_ttl: Some(3600), // 1 hour 704 + }, 705 + 706 + // Tech Events Theme 707 + ThemedPageConfig { 708 + id: "tech-events".to_string(), 709 + title: "Tech Events".to_string(), 710 + description: Some("Technology, programming, and software development events".to_string()), 711 + base_filters: EventFilters { 712 + extra_keys: Some(vec![ 713 + "tech".to_string(), "programming".to_string(), "software".to_string(), 714 + "development".to_string(), "coding".to_string(), "technology".to_string() 715 + ]), 716 + status: Some(vec!["scheduled".to_string(), "planned".to_string()]), 717 + sort_by: Some("starts_at".to_string()), 718 + ..Default::default() 719 + }, 720 + template_name: Some("tech_events".to_string()), 721 + css_class: Some("tech-theme".to_string()), 722 + enabled: true, 723 + seo_keywords: Some(vec!["tech".to_string(), "programming".to_string(), "software".to_string()]), 724 + cache_ttl: Some(3600), // 1 hour 725 + }, 726 + ], 727 + } 728 + } 729 + } 730 + 731 + pub fn load_themed_pages_config() -> ThemedPagesConfig { 732 + // Try to load from config file, fall back to defaults 733 + match std::fs::read_to_string("config/themed_pages.toml") { 734 + Ok(content) => toml::from_str(&content).unwrap_or_else(|e| { 735 + log::warn!("Failed to parse themed_pages.toml: {}. Using defaults.", e); 736 + ThemedPagesConfig::default() 737 + }), 738 + Err(_) => { 739 + log::info!("No themed_pages.toml found, using default configuration"); 740 + ThemedPagesConfig::default() 741 + } 742 + } 743 + } 744 + 745 + /// Validate themed page configuration 746 + pub fn validate_themed_config(config: &ThemedPagesConfig) -> Result<(), String> { 747 + let mut seen_ids = std::collections::HashSet::new(); 748 + 749 + for page in &config.pages { 750 + // Check for duplicate IDs 751 + if !seen_ids.insert(&page.id) { 752 + return Err(format!("Duplicate theme ID: {}", page.id)); 753 + } 754 + 755 + // Validate ID format (alphanumeric and hyphens only) 756 + if !page.id.chars().all(|c| c.is_alphanumeric() || c == '-') { 757 + return Err(format!("Invalid theme ID format: {}. Use only alphanumeric characters and hyphens.", page.id)); 758 + } 759 + 760 + // Validate template name if specified 761 + if let Some(template) = &page.template_name { 762 + if template.is_empty() { 763 + return Err(format!("Empty template name for theme: {}", page.id)); 764 + } 765 + } 766 + 767 + // Validate cache TTL 768 + if let Some(ttl) = page.cache_ttl { 769 + if ttl == 0 { 770 + return Err(format!("Cache TTL cannot be 0 for theme: {}", page.id)); 771 + } 772 + } 773 + } 774 + 775 + Ok(()) 776 + } 777 + ```` 778 + 779 + ### Phase 5: HTTP Handlers 780 + 781 + ````rust 782 + use crate::config::themed_pages::{load_themed_pages_config, validate_themed_config}; 783 + use crate::http::filters::EventFilters; 784 + use crate::services::event_filtering::EventFilteringService; 785 + use crate::AppState; 786 + use axum::{ 787 + extract::{Path, Query, State}, 788 + http::StatusCode, 789 + response::Html, 790 + Json, 791 + }; 792 + use serde_json::json; 793 + 794 + /// List all available themed pages 795 + pub async fn handle_themed_pages_index( 796 + State(app_state): State<AppState>, 797 + ) -> Result<Html<String>, StatusCode> { 798 + let themed_config = load_themed_pages_config(); 799 + 800 + // Validate configuration 801 + if let Err(e) = validate_themed_config(&themed_config) { 802 + log::error!("Invalid themed pages configuration: {}", e); 803 + return Err(StatusCode::INTERNAL_SERVER_ERROR); 804 + } 805 + 806 + let enabled_themes: Vec<_> = themed_config.pages 807 + .into_iter() 808 + .filter(|p| p.enabled) 809 + .collect(); 810 + 811 + let context = json!({ 812 + "themes": enabled_themes, 813 + "page_title": "Themed Event Pages", 814 + "page_description": "Discover events organized by type and category", 815 + }); 816 + 817 + let html = app_state.templates.render("themed_pages_index", &context) 818 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 819 + Ok(Html(html)) 820 + } 821 + 822 + /// Handle themed events page with optional user refinement 823 + pub async fn handle_themed_events_page( 824 + Path(theme_id): Path<String>, 825 + Query(user_filters): Query<EventFilters>, 826 + State(app_state): State<AppState>, 827 + ) -> Result<Html<String>, StatusCode> { 828 + let themed_config = load_themed_pages_config(); 829 + 830 + // Find the requested theme 831 + let theme = themed_config.pages 832 + .iter() 833 + .find(|p| p.id == theme_id && p.enabled) 834 + .ok_or(StatusCode::NOT_FOUND)?; 835 + 836 + // Merge user filters with theme base filters 837 + let final_filters = user_filters.merge_with_base(&theme.base_filters); 838 + 839 + // Apply filtering through service 840 + let filtering_service = EventFilteringService::new(app_state.hydration_cache.clone()); 841 + let (total_count, events) = filtering_service 842 + .filter_events(&app_state.storage_pool, &final_filters) 843 + .await 844 + .map_err(|e| { 845 + log::error!("Failed to filter events for theme {}: {}", theme_id, e); 846 + StatusCode::INTERNAL_SERVER_ERROR 847 + })?; 848 + 849 + // Render with theme-specific template 850 + let template_name = theme.template_name.as_deref().unwrap_or("events_themed"); 851 + let context = json!({ 852 + "events": events, 853 + "total_count": total_count, 854 + "theme": theme, 855 + "current_filters": final_filters, 856 + "page_title": theme.title, 857 + "page_description": theme.description, 858 + "css_class": theme.css_class, 859 + "seo_keywords": theme.seo_keywords, 860 + "theme_id": theme_id, 861 + }); 862 + 863 + let html = app_state.templates.render(template_name, &context) 864 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 865 + Ok(Html(html)) 866 + } 867 + 868 + /// Handle general events filtering (non-themed) 869 + pub async fn handle_events_filtered( 870 + Query(filters): Query<EventFilters>, 871 + State(app_state): State<AppState>, 872 + ) -> Result<Html<String>, StatusCode> { 873 + let filtering_service = EventFilteringService::new(app_state.hydration_cache.clone()); 874 + let (total_count, events) = filtering_service 875 + .filter_events(&app_state.storage_pool, &filters) 876 + .await 877 + .map_err(|e| { 878 + log::error!("Failed to filter events: {}", e); 879 + StatusCode::INTERNAL_SERVER_ERROR 880 + })?; 881 + 882 + let context = json!({ 883 + "events": events, 884 + "total_count": total_count, 885 + "current_filters": filters, 886 + "page_title": "All Events", 887 + "available_themes": load_themed_pages_config().pages.into_iter().filter(|p| p.enabled).collect::<Vec<_>>(), 888 + }); 889 + 890 + let html = app_state.templates.render("events_filtered", &context) 891 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 892 + Ok(Html(html)) 893 + } 894 + 895 + /// API endpoint for theme configuration (for admin/debugging) 896 + pub async fn handle_themed_config_api() -> Result<Json<serde_json::Value>, StatusCode> { 897 + let config = load_themed_pages_config(); 898 + 899 + if let Err(e) = validate_themed_config(&config) { 900 + return Ok(Json(json!({ 901 + "error": format!("Invalid configuration: {}", e), 902 + "valid": false 903 + }))); 904 + } 905 + 906 + Ok(Json(json!({ 907 + "config": config, 908 + "valid": true, 909 + "enabled_count": config.pages.iter().filter(|p| p.enabled).count() 910 + }))) 911 + } 912 + ```` 913 + 914 + ### Phase 6: Templates 915 + 916 + ````html 917 + <!DOCTYPE html> 918 + <html lang="en"> 919 + <head> 920 + <meta charset="UTF-8"> 921 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 922 + <title>{{page_title}}</title> 923 + <meta name="description" content="{{page_description}}"> 924 + <link rel="stylesheet" href="/css/themes.css"> 925 + </head> 926 + <body> 927 + <div class="themed-pages-index"> 928 + <header> 929 + <h1>{{page_title}}</h1> 930 + <p class="subtitle">{{page_description}}</p> 931 + </header> 932 + 933 + <div class="theme-grid"> 934 + {{#each themes}} 935 + <div class="theme-card {{css_class}}" data-theme-id="{{id}}"> 936 + <h3><a href="/events/themes/{{id}}">{{title}}</a></h3> 937 + {{#if description}} 938 + <p class="theme-description">{{description}}</p> 939 + {{/if}} 940 + {{#if seo_keywords}} 941 + <div class="theme-tags"> 942 + {{#each seo_keywords}} 943 + <span class="tag">{{this}}</span> 944 + {{/each}} 945 + </div> 946 + {{/if}} 947 + <a href="/events/themes/{{id}}" class="view-events-btn">View Events</a> 948 + </div> 949 + {{/each}} 950 + </div> 951 + 952 + <footer> 953 + <p><a href="/events">View all events</a> | <a href="/events/themes/api/config">Theme Config</a></p> 954 + </footer> 955 + </div> 956 + </body> 957 + </html> 958 + ```` 959 + 960 + ````html 961 + <!DOCTYPE html> 962 + <html lang="en"> 963 + <head> 964 + <meta charset="UTF-8"> 965 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 966 + <title>{{page_title}}</title> 967 + {{#if page_description}} 968 + <meta name="description" content="{{page_description}}"> 969 + {{/if}} 970 + {{#if seo_keywords}} 971 + <meta name="keywords" content="{{#each seo_keywords}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}"> 972 + {{/if}} 973 + <link rel="stylesheet" href="/css/events.css"> 974 + <link rel="stylesheet" href="/css/themes.css"> 975 + </head> 976 + <body class="{{css_class}}"> 977 + <div class="themed-events-page"> 978 + <header> 979 + <nav class="breadcrumb"> 980 + <a href="/events/themes">Themed Pages</a> > 981 + <span class="current">{{page_title}}</span> 982 + </nav> 983 + <h1>{{page_title}}</h1> 984 + {{#if page_description}} 985 + <p class="page-description">{{page_description}}</p> 986 + {{/if}} 987 + </header> 988 + 989 + <!-- Refinement filters form --> 990 + <section class="filter-section"> 991 + <h3>Refine Results</h3> 992 + <form method="get" class="theme-filters"> 993 + <div class="filter-group"> 994 + {{#unless current_filters.name}} 995 + <div class="filter-item"> 996 + <label for="name">Event name contains:</label> 997 + <input type="text" id="name" name="name" placeholder="Search event names..."> 998 + </div> 999 + {{/unless}} 1000 + 1001 + {{#unless current_filters.description}} 1002 + <div class="filter-item"> 1003 + <label for="description">Description contains:</label> 1004 + <input type="text" id="description" name="description" placeholder="Search descriptions..."> 1005 + </div> 1006 + {{/unless}} 1007 + 1008 + <!-- Date range refinement --> 1009 + <div class="filter-item"> 1010 + <label for="starts_after">From date:</label> 1011 + <input type="datetime-local" id="starts_after" name="starts_after"> 1012 + </div> 1013 + 1014 + <div class="filter-item"> 1015 + <label for="starts_before">To date:</label> 1016 + <input type="datetime-local" id="starts_before" name="starts_before"> 1017 + </div> 1018 + 1019 + <!-- Additional mode/status filters if not constrained by theme --> 1020 + {{#unless current_filters.mode}} 1021 + <div class="filter-item"> 1022 + <label for="mode">Event mode:</label> 1023 + <select id="mode" name="mode" multiple> 1024 + <option value="inperson">In Person</option> 1025 + <option value="virtual">Virtual</option> 1026 + <option value="hybrid">Hybrid</option> 1027 + </select> 1028 + </div> 1029 + {{/unless}} 1030 + </div> 1031 + 1032 + <div class="filter-actions"> 1033 + <button type="submit" class="btn btn-primary">Refine</button> 1034 + <a href="/events/themes/{{theme_id}}" class="btn btn-secondary">Reset to Theme Defaults</a> 1035 + </div> 1036 + </form> 1037 + </section> 1038 + 1039 + <!-- Events listing --> 1040 + <section class="events-section"> 1041 + <div class="results-header"> 1042 + <h2>Events ({{total_count}} found)</h2> 1043 + <div class="view-options"> 1044 + <a href="/events" class="btn btn-outline">View All Events</a> 1045 + <a href="/events/themes" class="btn btn-outline">Other Themes</a> 1046 + </div> 1047 + </div> 1048 + 1049 + {{#if events}} 1050 + <div class="events-list"> 1051 + {{#each events}} 1052 + <div class="event-card" data-event-id="{{event.id}}"> 1053 + <div class="event-header"> 1054 + <h3 class="event-title"> 1055 + <a href="/events/{{event.id}}">{{event.title}}</a> 1056 + </h3> 1057 + {{#if event.status}} 1058 + <span class="event-status status-{{event.status}}">{{event.status}}</span> 1059 + {{/if}} 1060 + </div> 1061 + 1062 + <div class="event-meta"> 1063 + {{#if event.starts_at}} 1064 + <div class="event-date"> 1065 + <i class="icon-calendar"></i> 1066 + <time datetime="{{event.starts_at}}">{{format_date event.starts_at}}</time> 1067 + </div> 1068 + {{/if}} 1069 + 1070 + {{#if event.mode}} 1071 + <div class="event-mode"> 1072 + <i class="icon-{{event.mode}}"></i> 1073 + <span>{{event.mode}}</span> 1074 + </div> 1075 + {{/if}} 1076 + 1077 + {{#if event.organizer}} 1078 + <div class="event-organizer"> 1079 + <i class="icon-user"></i> 1080 + <span>{{event.organizer.name}}</span> 1081 + </div> 1082 + {{/if}} 1083 + </div> 1084 + 1085 + {{#if event.description}} 1086 + <div class="event-description"> 1087 + <p>{{truncate event.description 150}}</p> 1088 + </div> 1089 + {{/if}} 1090 + 1091 + {{#if event.locations}} 1092 + <div class="event-locations"> 1093 + <i class="icon-location"></i> 1094 + {{#each event.locations}} 1095 + <span class="location">{{name}}</span>{{#unless @last}}, {{/unless}} 1096 + {{/each}} 1097 + </div> 1098 + {{/if}} 1099 + 1100 + <div class="event-actions"> 1101 + <a href="/events/{{event.id}}" class="btn btn-primary">View Details</a> 1102 + {{#if event.uris}} 1103 + {{#each event.uris}} 1104 + {{#if (eq type "tickets")}} 1105 + <a href="{{uri}}" class="btn btn-secondary" target="_blank" rel="noopener">Get Tickets</a> 1106 + {{/if}} 1107 + {{#if (eq type "registration")}} 1108 + <a href="{{uri}}" class="btn btn-secondary" target="_blank" rel="noopener">Register</a> 1109 + {{/if}} 1110 + {{/each}} 1111 + {{/if}} 1112 + </div> 1113 + </div> 1114 + {{/each}} 1115 + </div> 1116 + {{else}} 1117 + <div class="no-events"> 1118 + <h3>No events found</h3> 1119 + <p>Try adjusting your filters or <a href="/events/themes/{{theme_id}}">reset to theme defaults</a>.</p> 1120 + </div> 1121 + {{/if}} 1122 + </section> 1123 + 1124 + {{#if (gt total_count 20)}} 1125 + <section class="pagination"> 1126 + <!-- Pagination controls would go here --> 1127 + </section> 1128 + {{/if}} 1129 + </div> 1130 + 1131 + <script src="/js/theme-filters.js"></script> 1132 + </body> 1133 + </html> 1134 + ```` 1135 + 1136 + ### Phase 7: Tests 1137 + 1138 + ````rust 1139 + use crate::cache::event_hydration::{EventHydrationCache, HydratedEvent}; 1140 + use crate::http::filters::EventFilters; 1141 + use crate::services::event_filtering::EventFilteringService; 1142 + use chrono::{DateTime, Utc}; 1143 + use std::collections::HashMap; 1144 + 1145 + #[cfg(test)] 1146 + mod event_filtering_tests { 1147 + use super::*; 1148 + 1149 + #[tokio::test] 1150 + async fn test_text_filter_matching() { 1151 + let hydrated_event = create_test_event("Rust Programming Workshop", "Learn Rust programming language"); 1152 + let filters = EventFilters { 1153 + name: Some(vec!["rust".to_string()]), 1154 + ..Default::default() 1155 + }; 1156 + 1157 + let service = create_test_service().await; 1158 + assert!(service.should_include_event(&hydrated_event, &filters)); 1159 + 1160 + let filters_no_match = EventFilters { 1161 + name: Some(vec!["python".to_string()]), 1162 + ..Default::default() 1163 + }; 1164 + assert!(!service.should_include_event(&hydrated_event, &filters_no_match)); 1165 + } 1166 + 1167 + #[tokio::test] 1168 + async fn test_status_filter_matching() { 1169 + let mut hydrated_event = create_test_event("Test Event", "Description"); 1170 + hydrated_event.status = Some(Status::Scheduled); 1171 + 1172 + let filters = EventFilters { 1173 + status: Some(vec!["scheduled".to_string()]), 1174 + ..Default::default() 1175 + }; 1176 + 1177 + let service = create_test_service().await; 1178 + assert!(service.should_include_event(&hydrated_event, &filters)); 1179 + 1180 + let filters_no_match = EventFilters { 1181 + status: Some(vec!["cancelled".to_string()]), 1182 + ..Default::default() 1183 + }; 1184 + assert!(!service.should_include_event(&hydrated_event, &filters_no_match)); 1185 + } 1186 + 1187 + #[tokio::test] 1188 + async fn test_mode_filter_matching() { 1189 + let mut hydrated_event = create_test_event("Test Event", "Description"); 1190 + hydrated_event.mode = Some(Mode::Virtual); 1191 + 1192 + let filters = EventFilters { 1193 + mode: Some(vec!["virtual".to_string()]), 1194 + ..Default::default() 1195 + }; 1196 + 1197 + let service = create_test_service().await; 1198 + assert!(service.should_include_event(&hydrated_event, &filters)); 1199 + 1200 + let filters_no_match = EventFilters { 1201 + mode: Some(vec!["inperson".to_string()]), 1202 + ..Default::default() 1203 + }; 1204 + assert!(!service.should_include_event(&hydrated_event, &filters_no_match)); 1205 + } 1206 + 1207 + #[tokio::test] 1208 + async fn test_date_range_filtering() { 1209 + let mut hydrated_event = create_test_event("Test Event", "Description"); 1210 + let event_start = Utc::now() + chrono::Duration::days(5); 1211 + hydrated_event.starts_at = Some(event_start); 1212 + 1213 + // Test starts_after filter 1214 + let filters_after = EventFilters { 1215 + starts_after: Some(Utc::now() + chrono::Duration::days(3)), 1216 + ..Default::default() 1217 + }; 1218 + 1219 + let service = create_test_service().await; 1220 + assert!(service.should_include_event(&hydrated_event, &filters_after)); 1221 + 1222 + // Test starts_before filter 1223 + let filters_before = EventFilters { 1224 + starts_before: Some(Utc::now() + chrono::Duration::days(7)), 1225 + ..Default::default() 1226 + }; 1227 + assert!(service.should_include_event(&hydrated_event, &filters_before)); 1228 + 1229 + // Test out of range 1230 + let filters_out_of_range = EventFilters { 1231 + starts_after: Some(Utc::now() + chrono::Duration::days(10)), 1232 + ..Default::default() 1233 + }; 1234 + assert!(!service.should_include_event(&hydrated_event, &filters_out_of_range)); 1235 + } 1236 + 1237 + #[tokio::test] 1238 + async fn test_location_filtering() { 1239 + let mut hydrated_event = create_test_event("Test Event", "Description"); 1240 + hydrated_event.locations = vec![ 1241 + EventLocation { 1242 + name: "San Francisco Tech Hub".to_string(), 1243 + // ... other fields 1244 + }, 1245 + EventLocation { 1246 + name: "Virtual Platform".to_string(), 1247 + // ... other fields 1248 + }, 1249 + ]; 1250 + 1251 + let filters = EventFilters { 1252 + location_name: Some(vec!["san francisco".to_string()]), 1253 + ..Default::default() 1254 + }; 1255 + 1256 + let service = create_test_service().await; 1257 + assert!(service.should_include_event(&hydrated_event, &filters)); 1258 + 1259 + let filters_no_match = EventFilters { 1260 + location_name: Some(vec!["new york".to_string()]), 1261 + ..Default::default() 1262 + }; 1263 + assert!(!service.should_include_event(&hydrated_event, &filters_no_match)); 1264 + } 1265 + 1266 + #[tokio::test] 1267 + async fn test_extra_fields_filtering() { 1268 + let mut hydrated_event = create_test_event("Test Event", "Description"); 1269 + let mut extra = HashMap::new(); 1270 + extra.insert("category".to_string(), serde_json::Value::String("technology".to_string())); 1271 + extra.insert("cost".to_string(), serde_json::Value::String("free".to_string())); 1272 + hydrated_event.extra = extra; 1273 + 1274 + // Test extra keys filter 1275 + let filters_keys = EventFilters { 1276 + extra_keys: Some(vec!["category".to_string()]), 1277 + ..Default::default() 1278 + }; 1279 + 1280 + let service = create_test_service().await; 1281 + assert!(service.should_include_event(&hydrated_event, &filters_keys)); 1282 + 1283 + // Test extra values filter 1284 + let mut extra_values = HashMap::new(); 1285 + extra_values.insert("cost".to_string(), vec!["free".to_string()]); 1286 + let filters_values = EventFilters { 1287 + extra_values: Some(extra_values), 1288 + ..Default::default() 1289 + }; 1290 + assert!(service.should_include_event(&hydrated_event, &filters_values)); 1291 + 1292 + // Test no match 1293 + let mut extra_values_no_match = HashMap::new(); 1294 + extra_values_no_match.insert("cost".to_string(), vec!["paid".to_string()]); 1295 + let filters_no_match = EventFilters { 1296 + extra_values: Some(extra_values_no_match), 1297 + ..Default::default() 1298 + }; 1299 + assert!(!service.should_include_event(&hydrated_event, &filters_no_match)); 1300 + } 1301 + 1302 + #[tokio::test] 1303 + async fn test_filter_merging() { 1304 + let base_filters = EventFilters { 1305 + mode: Some(vec!["virtual".to_string()]), 1306 + status: Some(vec!["scheduled".to_string(), "planned".to_string()]), 1307 + starts_after: Some(Utc::now()), 1308 + ..Default::default() 1309 + }; 1310 + 1311 + let user_filters = EventFilters { 1312 + mode: Some(vec!["virtual".to_string(), "hybrid".to_string()]), 1313 + name: Some(vec!["conference".to_string()]), 1314 + starts_after: Some(Utc::now() + chrono::Duration::days(1)), 1315 + ..Default::default() 1316 + }; 1317 + 1318 + let merged = user_filters.merge_with_base(&base_filters); 1319 + 1320 + // Mode should be intersection (only "virtual") 1321 + assert_eq!(merged.mode, Some(vec!["virtual".to_string()])); 1322 + 1323 + // Name should come from user filters 1324 + assert_eq!(merged.name, Some(vec!["conference".to_string()])); 1325 + 1326 + // Status should come from base (user didn't specify) 1327 + assert_eq!(merged.status, Some(vec!["scheduled".to_string(), "planned".to_string()])); 1328 + 1329 + // starts_after should be the later date (more restrictive) 1330 + assert!(merged.starts_after.unwrap() > base_filters.starts_after.unwrap()); 1331 + } 1332 + 1333 + // Helper functions for tests 1334 + fn create_test_event(name: &str, description: &str) -> HydratedEvent { 1335 + HydratedEvent { 1336 + name: name.to_string(), 1337 + description: description.to_string(), 1338 + starts_at: None, 1339 + ends_at: None, 1340 + mode: None, 1341 + status: None, 1342 + locations: Vec::new(), 1343 + uris: Vec::new(), 1344 + extra: HashMap::new(), 1345 + } 1346 + } 1347 + 1348 + async fn create_test_service() -> EventFilteringService { 1349 + // Create mock cache for testing 1350 + let mock_cache = create_mock_hydration_cache().await; 1351 + EventFilteringService::new(mock_cache) 1352 + } 1353 + 1354 + async fn create_mock_hydration_cache() -> EventHydrationCache { 1355 + // Implementation would create a mock Redis pool for testing 1356 + unimplemented!("Create mock Redis pool for tests") 1357 + } 1358 + } 1359 + ```` 1360 + 1361 + ````rust 1362 + use crate::config::themed_pages::{load_themed_pages_config, validate_themed_config, ThemedPagesConfig, ThemedPageConfig}; 1363 + use crate::http::filters::EventFilters; 1364 + 1365 + #[cfg(test)] 1366 + mod themed_pages_tests { 1367 + use super::*; 1368 + 1369 + #[test] 1370 + fn test_default_themed_config() { 1371 + let config = ThemedPagesConfig::default(); 1372 + assert!(config.pages.len() > 0); 1373 + 1374 + // Validate the default configuration 1375 + assert!(validate_themed_config(&config).is_ok()); 1376 + 1377 + // Check that virtual events theme exists 1378 + let virtual_theme = config.pages.iter().find(|p| p.id == "virtual-events"); 1379 + assert!(virtual_theme.is_some()); 1380 + 1381 + let theme = virtual_theme.unwrap(); 1382 + assert_eq!(theme.title, "Virtual Events"); 1383 + assert!(theme.enabled); 1384 + assert!(theme.base_filters.mode.as_ref().unwrap().contains(&"virtual".to_string())); 1385 + } 1386 + 1387 + #[test] 1388 + fn test_themed_config_validation() { 1389 + // Test valid configuration 1390 + let valid_config = ThemedPagesConfig { 1391 + pages: vec![ 1392 + ThemedPageConfig { 1393 + id: "test-theme".to_string(), 1394 + title: "Test Theme".to_string(), 1395 + description: Some("Test description".to_string()), 1396 + base_filters: EventFilters::default(), 1397 + template_name: Some("test_template".to_string()), 1398 + css_class: Some("test-class".to_string()), 1399 + enabled: true, 1400 + seo_keywords: None, 1401 + cache_ttl: Some(3600), 1402 + }, 1403 + ], 1404 + }; 1405 + assert!(validate_themed_config(&valid_config).is_ok()); 1406 + 1407 + // Test duplicate IDs 1408 + let duplicate_config = ThemedPagesConfig { 1409 + pages: vec![ 1410 + ThemedPageConfig { 1411 + id: "duplicate".to_string(), 1412 + title: "First".to_string(), 1413 + description: None, 1414 + base_filters: EventFilters::default(), 1415 + template_name: None, 1416 + css_class: None, 1417 + enabled: true, 1418 + seo_keywords: None, 1419 + cache_ttl: None, 1420 + }, 1421 + ThemedPageConfig { 1422 + id: "duplicate".to_string(), 1423 + title: "Second".to_string(), 1424 + description: None, 1425 + base_filters: EventFilters::default(), 1426 + template_name: None, 1427 + css_class: None, 1428 + enabled: true, 1429 + seo_keywords: None, 1430 + cache_ttl: None, 1431 + }, 1432 + ], 1433 + }; 1434 + assert!(validate_themed_config(&duplicate_config).is_err()); 1435 + 1436 + // Test invalid ID format 1437 + let invalid_id_config = ThemedPagesConfig { 1438 + pages: vec![ 1439 + ThemedPageConfig { 1440 + id: "invalid/id".to_string(), 1441 + title: "Invalid".to_string(), 1442 + description: None, 1443 + base_filters: EventFilters::default(), 1444 + template_name: None, 1445 + css_class: None, 1446 + enabled: true, 1447 + seo_keywords: None, 1448 + cache_ttl: None, 1449 + }, 1450 + ], 1451 + }; 1452 + assert!(validate_themed_config(&invalid_id_config).is_err()); 1453 + 1454 + // Test zero cache TTL 1455 + let zero_ttl_config = ThemedPagesConfig { 1456 + pages: vec![ 1457 + ThemedPageConfig { 1458 + id: "zero-ttl".to_string(), 1459 + title: "Zero TTL".to_string(), 1460 + description: None, 1461 + base_filters: EventFilters::default(), 1462 + template_name: None, 1463 + css_class: None, 1464 + enabled: true, 1465 + seo_keywords: None, 1466 + cache_ttl: Some(0), 1467 + }, 1468 + ], 1469 + }; 1470 + assert!(validate_themed_config(&zero_ttl_config).is_err()); 1471 + } 1472 + 1473 + #[test] 1474 + fn test_filter_merging_edge_cases() { 1475 + let base = EventFilters { 1476 + status: Some(vec!["scheduled".to_string()]), 1477 + mode: Some(vec!["virtual".to_string(), "hybrid".to_string()]), 1478 + ..Default::default() 1479 + }; 1480 + 1481 + // User specifies mode not in base - should fall back to base 1482 + let user = EventFilters { 1483 + mode: Some(vec!["inperson".to_string()]), 1484 + ..Default::default() 1485 + }; 1486 + 1487 + let merged = user.merge_with_base(&base); 1488 + assert_eq!(merged.mode, Some(vec!["virtual".to_string(), "hybrid".to_string()])); 1489 + 1490 + // User specifies overlapping mode - should intersect 1491 + let user_overlap = EventFilters { 1492 + mode: Some(vec!["virtual".to_string()]), 1493 + ..Default::default() 1494 + }; 1495 + 1496 + let merged_overlap = user_overlap.merge_with_base(&base); 1497 + assert_eq!(merged_overlap.mode, Some(vec!["virtual".to_string()])); 1498 + } 1499 + } 1500 + ````
-710
src/http/handle_events_filtered_backup.rs
··· 1 - use anyhow::Result; 2 - use axum::{ 3 - extract::{Query, State, Form}, 4 - response::IntoResponse, 5 - }; 6 - use axum_extra::extract::Cached; 7 - use axum_htmx::{HxBoosted, HxRequest}; 8 - use axum_template::RenderHtml; 9 - 10 - use chrono::{DateTime, TimeZone, Utc}; 11 - use minijinja::context as template_context; 12 - use serde::{Deserialize, Serialize}; 13 - 14 - use crate::{ 15 - contextual_error, 16 - http::{ 17 - context::WebContext, 18 - errors::WebError, 19 - event_view::{hydrate_event_organizers, hydrate_event_rsvp_counts, EventView}, 20 - middleware_auth::Auth, 21 - middleware_i18n::Language, 22 - pagination::{Pagination, PaginationView}, 23 - }, 24 - select_template, 25 - storage::{ 26 - event::model::EventWithRole, 27 - event_filter::{event_list_filtered, EventFilterOptions}, 28 - }, 29 - }; 30 - 31 - #[derive(Debug, Deserialize, Serialize, Clone)] 32 - pub struct EventFilters { 33 - status: Option<String>, 34 - mode: Option<String>, 35 - starts_after: Option<String>, // Format ISO 8601 or YYYY-MM-DD 36 - starts_after_time: Option<String>, // Format HH:MM 37 - starts_before: Option<String>, // Format ISO 8601 or YYYY-MM-DD 38 - starts_before_time: Option<String>, // Format HH:MM 39 - organizer: Option<String>, 40 - sort_by: Option<String>, // "starts_at" for sorting by start date, "updated_at" (default) for recently updated 41 - } 42 - 43 - pub async fn handle_events_filtered( 44 - State(web_context): State<WebContext>, 45 - HxBoosted(hx_boosted): HxBoosted, 46 - HxRequest(hx_request): HxRequest, 47 - Language(language): Language, 48 - Cached(auth): Cached<Auth>, 49 - pagination: Query<Pagination>, 50 - filters: Query<EventFilters>, 51 - ) -> Result<impl IntoResponse, WebError> { 52 - // Déléguer à la fonction commune avec les filtres de l'URL (GET) 53 - handle_events_filtered_common( 54 - web_context, 55 - hx_boosted, 56 - hx_request, 57 - language, 58 - auth, 59 - pagination.0, 60 - filters.0, 61 - ).await 62 - } 63 - 64 - // Handler pour les requêtes POST (formulaires) 65 - pub async fn handle_events_filtered_post( 66 - State(web_context): State<WebContext>, 67 - HxBoosted(hx_boosted): HxBoosted, 68 - HxRequest(hx_request): HxRequest, 69 - Language(language): Language, 70 - Cached(auth): Cached<Auth>, 71 - pagination: Query<Pagination>, 72 - Form(filters): Form<EventFilters>, 73 - ) -> Result<impl IntoResponse, WebError> { 74 - // Déléguer à la fonction commune avec les filtres du formulaire 75 - handle_events_filtered_common( 76 - web_context, 77 - hx_boosted, 78 - hx_request, 79 - language, 80 - auth, 81 - pagination.0, 82 - filters, 83 - ).await 84 - } 85 - 86 - // Fonction commune pour traiter les filtres d'événements 87 - async fn handle_events_filtered_common( 88 - web_context: WebContext, 89 - hx_boosted: bool, 90 - hx_request: bool, 91 - language: Language, 92 - auth: Auth, 93 - pagination: Pagination, 94 - filters: EventFilters, 95 - ) -> Result<impl IntoResponse, WebError> { 96 - let render_template = select_template!("events_filtered", hx_boosted, hx_request, language); 97 - let error_template = select_template!(false, false, language); 98 - 99 - let (page, page_size) = pagination.clamped(); 100 - 101 - // Create a mutable copy of the filters to be able to add default values if necessary 102 - let mut filters_with_defaults = filters.clone(); 103 - 104 - // If no date is specified (first visit to the page), we add default values 105 - let is_first_visit = filters.starts_after.as_ref().map_or(true, |s| s.is_empty() || s == "none") && 106 - filters.starts_before.as_ref().map_or(true, |s| s.is_empty() || s == "none"); 107 - 108 - if is_first_visit { 109 - // Today 110 - let today = chrono::Local::now().date_naive(); 111 - // Tomorrow (today + 24h) 112 - let tomorrow = today.succ_opt().unwrap_or(today); 113 - 114 - // Date format as YYYY-MM-DD 115 - filters_with_defaults.starts_after = Some(today.format("%Y-%m-%d").to_string()); 116 - filters_with_defaults.starts_after_time = Some("00:00".to_string()); 117 - filters_with_defaults.starts_before = Some(tomorrow.format("%Y-%m-%d").to_string()); 118 - filters_with_defaults.starts_before_time = Some("23:59".to_string()); 119 - 120 - tracing::debug!("Adding default values for dates: {} - {}", 121 - filters_with_defaults.starts_after.as_ref().unwrap(), 122 - filters_with_defaults.starts_before.as_ref().unwrap()); 123 - } 124 - 125 - // Date and time conversion if present 126 - let starts_after = filters_with_defaults.starts_after.as_ref() 127 - .filter(|&date_str| !date_str.is_empty() && date_str != "none") 128 - .and_then(|date_str| { 129 - let time_str = filters_with_defaults.starts_after_time.as_deref().unwrap_or("00:00"); 130 - 131 - // Try parsing in YYYY-MM-DD format (HTML input type=date format) 132 - if let Some(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok() { 133 - let time = chrono::NaiveTime::parse_from_str(time_str, "%H:%M") 134 - .unwrap_or_else(|_| chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()); 135 - 136 - // Convert NaiveDate to DateTime<Utc> with specified time 137 - let naive_datetime = chrono::NaiveDateTime::new(date, time); 138 - Some(Utc.from_utc_datetime(&naive_datetime)) 139 - } else { 140 - // Fallback to RFC3339 format 141 - DateTime::parse_from_rfc3339(date_str).ok().map(|date| date.with_timezone(&Utc)) 142 - } 143 - }); 144 - 145 - let starts_before = filters_with_defaults.starts_before.as_ref() 146 - .filter(|&date_str| !date_str.is_empty() && date_str != "none") 147 - .and_then(|date_str| { 148 - let time_str = filters_with_defaults.starts_before_time.as_deref().unwrap_or("23:59"); 149 - 150 - // Try parsing in YYYY-MM-DD format (HTML input type=date format) 151 - if let Some(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok() { 152 - let time = chrono::NaiveTime::parse_from_str(time_str, "%H:%M") 153 - .unwrap_or_else(|_| chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap()); 154 - 155 - // Convert NaiveDate to DateTime<Utc> with specified time 156 - let naive_datetime = chrono::NaiveDateTime::new(date, time); 157 - Some(Utc.from_utc_datetime(&naive_datetime)) 158 - } else { 159 - // Fallback to RFC3339 format 160 - DateTime::parse_from_rfc3339(date_str).ok().map(|date| date.with_timezone(&Utc)) 161 - } 162 - }); 163 - 164 - // Create filtering options 165 - let filter_options = EventFilterOptions { 166 - status: filters_with_defaults.status.clone(), 167 - mode: filters_with_defaults.mode.clone(), 168 - starts_after, 169 - starts_before, 170 - organizer_did: filters_with_defaults.organizer.clone(), 171 - sort_by: filters_with_defaults.sort_by.clone(), 172 - }; 173 - 174 - // Log filtering parameters for debugging 175 - tracing::debug!( 176 - "Event filtering: status={:?}, mode={:?}, starts_after={:?} {:?} ({:?}), starts_before={:?} {:?} ({:?}), organizer={:?}, sort_by={:?}", 177 - filters_with_defaults.status, filters_with_defaults.mode, 178 - filters_with_defaults.starts_after, filters_with_defaults.starts_after_time, starts_after, 179 - filters_with_defaults.starts_before, filters_with_defaults.starts_before_time, starts_before, 180 - filters_with_defaults.organizer, filters_with_defaults.sort_by 181 - ); 182 - 183 - // Retrieve filtered events 184 - let (total_count, events) = match event_list_filtered(&web_context.pool, filter_options, page, page_size).await { 185 - Ok((count, events)) => { 186 - tracing::debug!("Events found: {}", count); 187 - (count, events) 188 - }, 189 - Err(err) => { 190 - return contextual_error!( 191 - web_context, 192 - language, 193 - error_template, 194 - template_context! {}, 195 - err 196 - ); 197 - } 198 - }; 199 - 200 - // Convert to EventWithRole for hydration 201 - let event_with_roles = events.into_iter() 202 - .map(|event| EventWithRole { 203 - event, 204 - role: "".to_string(), 205 - }) 206 - .collect::<Vec<_>>(); 207 - 208 - let organizer_handlers = hydrate_event_organizers(&web_context.pool, &event_with_roles).await?; 209 - 210 - // Convert to event views 211 - let mut event_views = event_with_roles 212 - .iter() 213 - .filter_map(|event_role| { 214 - let organizer_maybe = organizer_handlers.get(&event_role.event.did); 215 - let event_view = 216 - EventView::try_from((auth.0.as_ref(), organizer_maybe, &event_role.event)); 217 - 218 - match event_view { 219 - Ok(event_view) => Some(event_view), 220 - Err(err) => { 221 - tracing::warn!(err = ?err, "error converting event view"); 222 - None 223 - } 224 - } 225 - }) 226 - .collect::<Vec<EventView>>(); 227 - 228 - // Add RSVP counts 229 - if let Err(err) = hydrate_event_rsvp_counts(&web_context.pool, &mut event_views).await { 230 - tracing::warn!("Failed to hydrate event counts: {}", err); 231 - } 232 - 233 - // Prepare parameters for pagination view 234 - let mut params: Vec<(&str, &str)> = Vec::new(); 235 - if let Some(status) = &filters_with_defaults.status { 236 - params.push(("status", status)); 237 - } 238 - if let Some(mode) = &filters_with_defaults.mode { 239 - params.push(("mode", mode)); 240 - } 241 - if let Some(starts_after) = &filters_with_defaults.starts_after { 242 - params.push(("starts_after", starts_after)); 243 - } 244 - if let Some(starts_after_time) = &filters_with_defaults.starts_after_time { 245 - params.push(("starts_after_time", starts_after_time)); 246 - } 247 - if let Some(starts_before) = &filters_with_defaults.starts_before { 248 - params.push(("starts_before", starts_before)); 249 - } 250 - if let Some(starts_before_time) = &filters_with_defaults.starts_before_time { 251 - params.push(("starts_before_time", starts_before_time)); 252 - } 253 - if let Some(organizer) = &filters_with_defaults.organizer { 254 - params.push(("organizer", organizer)); 255 - } 256 - if let Some(sort_by) = &filters_with_defaults.sort_by { 257 - params.push(("sort_by", sort_by)); 258 - } 259 - 260 - let pagination_view = PaginationView::new(page_size, total_count, page, params); 261 - 262 - // Limit results to page size 263 - if event_views.len() > page_size as usize { 264 - event_views.truncate(page_size as usize); 265 - } 266 - 267 - // Build context for template 268 - Ok(( 269 - http::StatusCode::OK, 270 - RenderHtml( 271 - &render_template, 272 - web_context.engine.clone(), 273 - template_context! { 274 - current_handle => auth.0, 275 - language => language.to_string(), 276 - canonical_url => format!("https://{}/events", web_context.config.external_base), 277 - events => event_views, 278 - pagination => pagination_view, 279 - filters => filters_with_defaults, 280 - total_count => total_count, 281 - }, 282 - ), 283 - ) 284 - .into_response()) 285 - } 286 - 287 - /// Handler pour les requêtes HTMX partielles de mise à jour des résultats d'événements 288 - /// Utilisé par le formulaire de filtres pour mettre à jour la zone des résultats 289 - pub async fn handle_events_results( 290 - State(web_context): State<WebContext>, 291 - Language(language): Language, 292 - Cached(auth): Cached<Auth>, 293 - pagination: Query<Pagination>, 294 - filters: Query<EventFilters>, 295 - ) -> Result<impl IntoResponse, WebError> { 296 - // Toujours utiliser le template partial pour les résultats 297 - let render_template = select_template!("events_filtered", false, true, language); 298 - let error_template = select_template!(false, false, language); 299 - 300 - // Réutiliser la même logique de filtrage que le handler principal 301 - let (page, page_size) = pagination.clamped(); 302 - 303 - // Use the filters directly without needing to make them mutable 304 - let filters_with_defaults = filters.0.clone(); 305 - 306 - // Date and time conversion if present 307 - let starts_after = filters_with_defaults.starts_after.as_ref() 308 - .filter(|&date_str| !date_str.is_empty() && date_str != "none") 309 - .and_then(|date_str| { 310 - let time_str = filters_with_defaults.starts_after_time.as_deref().unwrap_or("00:00"); 311 - 312 - // Try parsing in YYYY-MM-DD format (HTML input type=date format) 313 - if let Some(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok() { 314 - let time = chrono::NaiveTime::parse_from_str(time_str, "%H:%M") 315 - .unwrap_or_else(|_| chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()); 316 - 317 - // Convert NaiveDate to DateTime<Utc> with specified time 318 - let naive_datetime = chrono::NaiveDateTime::new(date, time); 319 - Some(Utc.from_utc_datetime(&naive_datetime)) 320 - } else { 321 - // Fallback to RFC3339 format 322 - DateTime::parse_from_rfc3339(date_str).ok().map(|date| date.with_timezone(&Utc)) 323 - } 324 - }); 325 - 326 - let starts_before = filters_with_defaults.starts_before.as_ref() 327 - .filter(|&date_str| !date_str.is_empty() && date_str != "none") 328 - .and_then(|date_str| { 329 - let time_str = filters_with_defaults.starts_before_time.as_deref().unwrap_or("23:59"); 330 - 331 - // Try parsing in YYYY-MM-DD format (HTML input type=date format) 332 - if let Some(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok() { 333 - let time = chrono::NaiveTime::parse_from_str(time_str, "%H:%M") 334 - .unwrap_or_else(|_| chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap()); 335 - 336 - // Convert NaiveDate to DateTime<Utc> with specified time 337 - let naive_datetime = chrono::NaiveDateTime::new(date, time); 338 - Some(Utc.from_utc_datetime(&naive_datetime)) 339 - } else { 340 - // Fallback to RFC3339 format 341 - DateTime::parse_from_rfc3339(date_str).ok().map(|date| date.with_timezone(&Utc)) 342 - } 343 - }); 344 - 345 - // Create filtering options 346 - let filter_options = EventFilterOptions { 347 - status: filters_with_defaults.status.clone(), 348 - mode: filters_with_defaults.mode.clone(), 349 - starts_after, 350 - starts_before, 351 - organizer_did: filters_with_defaults.organizer.clone(), 352 - sort_by: filters_with_defaults.sort_by.clone(), 353 - }; 354 - 355 - // Log filtering parameters for debugging 356 - tracing::debug!( 357 - "Event filtering (HTMX partial): status={:?}, mode={:?}, starts_after={:?} {:?} ({:?}), starts_before={:?} {:?} ({:?}), organizer={:?}, sort_by={:?}", 358 - filters_with_defaults.status, filters_with_defaults.mode, 359 - filters_with_defaults.starts_after, filters_with_defaults.starts_after_time, starts_after, 360 - filters_with_defaults.starts_before, filters_with_defaults.starts_before_time, starts_before, 361 - filters_with_defaults.organizer, filters_with_defaults.sort_by 362 - ); 363 - 364 - // Retrieve filtered events 365 - let (total_count, events) = match event_list_filtered(&web_context.pool, filter_options, page, page_size).await { 366 - Ok((count, events)) => { 367 - tracing::debug!("Events found (HTMX partial): {}", count); 368 - (count, events) 369 - }, 370 - Err(err) => { 371 - return contextual_error!( 372 - web_context, 373 - language, 374 - error_template, 375 - template_context! {}, 376 - err 377 - ); 378 - } 379 - }; 380 - 381 - // Convert to EventWithRole for hydration 382 - let event_with_roles = events.into_iter() 383 - .map(|event| EventWithRole { 384 - event, 385 - role: "".to_string(), 386 - }) 387 - .collect::<Vec<_>>(); 388 - 389 - let organizer_handlers = hydrate_event_organizers(&web_context.pool, &event_with_roles).await?; 390 - 391 - // Convert to event views 392 - let mut event_views = event_with_roles 393 - .iter() 394 - .filter_map(|event_role| { 395 - let organizer_maybe = organizer_handlers.get(&event_role.event.did); 396 - let event_view = 397 - EventView::try_from((auth.0.as_ref(), organizer_maybe, &event_role.event)); 398 - 399 - match event_view { 400 - Ok(event_view) => Some(event_view), 401 - Err(err) => { 402 - tracing::warn!(err = ?err, "error converting event view"); 403 - None 404 - } 405 - } 406 - }) 407 - .collect::<Vec<EventView>>(); 408 - 409 - // Add RSVP counts 410 - if let Err(err) = hydrate_event_rsvp_counts(&web_context.pool, &mut event_views).await { 411 - tracing::warn!("Failed to hydrate event counts: {}", err); 412 - } 413 - 414 - // Prepare parameters for pagination view 415 - let mut params: Vec<(&str, &str)> = Vec::new(); 416 - if let Some(status) = &filters_with_defaults.status { 417 - params.push(("status", status)); 418 - } 419 - if let Some(mode) = &filters_with_defaults.mode { 420 - params.push(("mode", mode)); 421 - } 422 - if let Some(starts_after) = &filters_with_defaults.starts_after { 423 - params.push(("starts_after", starts_after)); 424 - } 425 - if let Some(starts_after_time) = &filters_with_defaults.starts_after_time { 426 - params.push(("starts_after_time", starts_after_time)); 427 - } 428 - if let Some(starts_before) = &filters_with_defaults.starts_before { 429 - params.push(("starts_before", starts_before)); 430 - } 431 - if let Some(starts_before_time) = &filters_with_defaults.starts_before_time { 432 - params.push(("starts_before_time", starts_before_time)); 433 - } 434 - if let Some(organizer) = &filters_with_defaults.organizer { 435 - params.push(("organizer", organizer)); 436 - } 437 - if let Some(sort_by) = &filters_with_defaults.sort_by { 438 - params.push(("sort_by", sort_by)); 439 - } 440 - 441 - let pagination_view = PaginationView::new(page_size, total_count, page, params); 442 - 443 - // Limit results to page size 444 - if event_views.len() > page_size as usize { 445 - event_views.truncate(page_size as usize); 446 - } 447 - 448 - // Build context for template (partial) 449 - Ok(( 450 - http::StatusCode::OK, 451 - RenderHtml( 452 - &render_template, 453 - web_context.engine.clone(), 454 - template_context! { 455 - current_handle => auth.0, 456 - language => language.to_string(), 457 - events => event_views, 458 - pagination => pagination_view, 459 - filters => filters_with_defaults, 460 - total_count => total_count, 461 - }, 462 - ), 463 - ) 464 - .into_response()) 465 - } 466 - 467 - /// Handler pour les requêtes POST (formulaires) 468 - pub async fn handle_events_filtered_post( 469 - State(web_context): State<WebContext>, 470 - HxBoosted(hx_boosted): HxBoosted, 471 - HxRequest(hx_request): HxRequest, 472 - Language(language): Language, 473 - Cached(auth): Cached<Auth>, 474 - pagination: Query<Pagination>, 475 - Form(filters): Form<EventFilters>, 476 - ) -> Result<impl IntoResponse, WebError> { 477 - // Déléguer à la fonction commune avec les filtres du formulaire 478 - handle_events_filtered_common( 479 - web_context, 480 - hx_boosted, 481 - hx_request, 482 - language, 483 - auth, 484 - pagination.0, 485 - filters, 486 - ).await 487 - } 488 - 489 - /// Handler pour les requêtes GET (liens directs) 490 - pub async fn handle_events_filtered_get( 491 - State(web_context): State<WebContext>, 492 - HxBoosted(hx_boosted): HxBoosted, 493 - HxRequest(hx_request): HxRequest, 494 - Language(language): Language, 495 - Cached(auth): Cached<Auth>, 496 - pagination: Query<Pagination>, 497 - filters: Query<EventFilters>, 498 - ) -> Result<impl IntoResponse, WebError> { 499 - // Déléguer à la fonction commune avec les filtres de l'URL 500 - handle_events_filtered_common( 501 - web_context, 502 - hx_boosted, 503 - hx_request, 504 - language, 505 - auth, 506 - pagination.0, 507 - filters.0, 508 - ).await 509 - } 510 - 511 - /// Fonction commune pour traiter les filtres d'événements 512 - async fn handle_events_filtered_common( 513 - web_context: WebContext, 514 - hx_boosted: bool, 515 - hx_request: bool, 516 - language: Language, 517 - auth: Auth, 518 - pagination: Pagination, 519 - filters: EventFilters, 520 - ) -> Result<impl IntoResponse, WebError> { 521 - let render_template = select_template!("events_filtered", hx_boosted, hx_request, language); 522 - let error_template = select_template!(false, false, language); 523 - 524 - let (page, page_size) = pagination.clamped(); 525 - 526 - // Create a mutable copy of the filters to be able to add default values if necessary 527 - let mut filters_with_defaults = filters.clone(); 528 - 529 - // If no date is specified (first visit to the page), we add default values 530 - let is_first_visit = filters.starts_after.as_ref().map_or(true, |s| s.is_empty() || s == "none") && 531 - filters.starts_before.as_ref().map_or(true, |s| s.is_empty() || s == "none"); 532 - 533 - if is_first_visit { 534 - // Today 535 - let today = chrono::Local::now().date_naive(); 536 - // Tomorrow (today + 24h) 537 - let tomorrow = today.succ_opt().unwrap_or(today); 538 - 539 - // Date format as YYYY-MM-DD 540 - filters_with_defaults.starts_after = Some(today.format("%Y-%m-%d").to_string()); 541 - filters_with_defaults.starts_after_time = Some("00:00".to_string()); 542 - filters_with_defaults.starts_before = Some(tomorrow.format("%Y-%m-%d").to_string()); 543 - filters_with_defaults.starts_before_time = Some("23:59".to_string()); 544 - 545 - tracing::debug!("Adding default values for dates: {} - {}", 546 - filters_with_defaults.starts_after.as_ref().unwrap(), 547 - filters_with_defaults.starts_before.as_ref().unwrap()); 548 - } 549 - 550 - // Date and time conversion if present 551 - let starts_after = filters_with_defaults.starts_after.as_ref() 552 - .filter(|&date_str| !date_str.is_empty() && date_str != "none") 553 - .and_then(|date_str| { 554 - let time_str = filters_with_defaults.starts_after_time.as_deref().unwrap_or("00:00"); 555 - 556 - // Try parsing in YYYY-MM-DD format (HTML input type=date format) 557 - if let Some(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok() { 558 - let time = chrono::NaiveTime::parse_from_str(time_str, "%H:%M") 559 - .unwrap_or_else(|_| chrono::NaiveTime::from_hms_opt(0, 0, 0).unwrap()); 560 - 561 - // Convert NaiveDate to DateTime<Utc> with specified time 562 - let naive_datetime = chrono::NaiveDateTime::new(date, time); 563 - Some(Utc.from_utc_datetime(&naive_datetime)) 564 - } else { 565 - // Fallback to RFC3339 format 566 - DateTime::parse_from_rfc3339(date_str).ok().map(|date| date.with_timezone(&Utc)) 567 - } 568 - }); 569 - 570 - let starts_before = filters_with_defaults.starts_before.as_ref() 571 - .filter(|&date_str| !date_str.is_empty() && date_str != "none") 572 - .and_then(|date_str| { 573 - let time_str = filters_with_defaults.starts_before_time.as_deref().unwrap_or("23:59"); 574 - 575 - // Try parsing in YYYY-MM-DD format (HTML input type=date format) 576 - if let Some(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").ok() { 577 - let time = chrono::NaiveTime::parse_from_str(time_str, "%H:%M") 578 - .unwrap_or_else(|_| chrono::NaiveTime::from_hms_opt(23, 59, 59).unwrap()); 579 - 580 - // Convert NaiveDate to DateTime<Utc> with specified time 581 - let naive_datetime = chrono::NaiveDateTime::new(date, time); 582 - Some(Utc.from_utc_datetime(&naive_datetime)) 583 - } else { 584 - // Fallback to RFC3339 format 585 - DateTime::parse_from_rfc3339(date_str).ok().map(|date| date.with_timezone(&Utc)) 586 - } 587 - }); 588 - 589 - // Create filtering options 590 - let filter_options = EventFilterOptions { 591 - status: filters_with_defaults.status.clone(), 592 - mode: filters_with_defaults.mode.clone(), 593 - starts_after, 594 - starts_before, 595 - organizer_did: filters_with_defaults.organizer.clone(), 596 - sort_by: filters_with_defaults.sort_by.clone(), 597 - }; 598 - 599 - // Log filtering parameters for debugging 600 - tracing::debug!( 601 - "Event filtering: status={:?}, mode={:?}, starts_after={:?} {:?} ({:?}), starts_before={:?} {:?} ({:?}), organizer={:?}, sort_by={:?}", 602 - filters_with_defaults.status, filters_with_defaults.mode, 603 - filters_with_defaults.starts_after, filters_with_defaults.starts_after_time, starts_after, 604 - filters_with_defaults.starts_before, filters_with_defaults.starts_before_time, starts_before, 605 - filters_with_defaults.organizer, filters_with_defaults.sort_by 606 - ); 607 - 608 - // Retrieve filtered events 609 - let (total_count, events) = match event_list_filtered(&web_context.pool, filter_options, page, page_size).await { 610 - Ok((count, events)) => { 611 - tracing::debug!("Events found: {}", count); 612 - (count, events) 613 - }, 614 - Err(err) => { 615 - return contextual_error!( 616 - web_context, 617 - language, 618 - error_template, 619 - template_context! {}, 620 - err 621 - ); 622 - } 623 - }; 624 - 625 - // Convert to EventWithRole for hydration 626 - let event_with_roles = events.into_iter() 627 - .map(|event| EventWithRole { 628 - event, 629 - role: "".to_string(), 630 - }) 631 - .collect::<Vec<_>>(); 632 - 633 - let organizer_handlers = hydrate_event_organizers(&web_context.pool, &event_with_roles).await?; 634 - 635 - // Convert to event views 636 - let mut event_views = event_with_roles 637 - .iter() 638 - .filter_map(|event_role| { 639 - let organizer_maybe = organizer_handlers.get(&event_role.event.did); 640 - let event_view = 641 - EventView::try_from((auth.0.as_ref(), organizer_maybe, &event_role.event)); 642 - 643 - match event_view { 644 - Ok(event_view) => Some(event_view), 645 - Err(err) => { 646 - tracing::warn!(err = ?err, "error converting event view"); 647 - None 648 - } 649 - } 650 - }) 651 - .collect::<Vec<EventView>>(); 652 - 653 - // Add RSVP counts 654 - if let Err(err) = hydrate_event_rsvp_counts(&web_context.pool, &mut event_views).await { 655 - tracing::warn!("Failed to hydrate event counts: {}", err); 656 - } 657 - 658 - // Prepare parameters for pagination view 659 - let mut params: Vec<(&str, &str)> = Vec::new(); 660 - if let Some(status) = &filters_with_defaults.status { 661 - params.push(("status", status)); 662 - } 663 - if let Some(mode) = &filters_with_defaults.mode { 664 - params.push(("mode", mode)); 665 - } 666 - if let Some(starts_after) = &filters_with_defaults.starts_after { 667 - params.push(("starts_after", starts_after)); 668 - } 669 - if let Some(starts_after_time) = &filters_with_defaults.starts_after_time { 670 - params.push(("starts_after_time", starts_after_time)); 671 - } 672 - if let Some(starts_before) = &filters_with_defaults.starts_before { 673 - params.push(("starts_before", starts_before)); 674 - } 675 - if let Some(starts_before_time) = &filters_with_defaults.starts_before_time { 676 - params.push(("starts_before_time", starts_before_time)); 677 - } 678 - if let Some(organizer) = &filters_with_defaults.organizer { 679 - params.push(("organizer", organizer)); 680 - } 681 - if let Some(sort_by) = &filters_with_defaults.sort_by { 682 - params.push(("sort_by", sort_by)); 683 - } 684 - 685 - let pagination_view = PaginationView::new(page_size, total_count, page, params); 686 - 687 - // Limit results to page size 688 - if event_views.len() > page_size as usize { 689 - event_views.truncate(page_size as usize); 690 - } 691 - 692 - // Build context for template 693 - Ok(( 694 - http::StatusCode::OK, 695 - RenderHtml( 696 - &render_template, 697 - web_context.engine.clone(), 698 - template_context! { 699 - current_handle => auth.0, 700 - language => language.to_string(), 701 - canonical_url => format!("https://{}/events", web_context.config.external_base), 702 - events => event_views, 703 - pagination => pagination_view, 704 - filters => filters_with_defaults, 705 - total_count => total_count, 706 - }, 707 - ), 708 - ) 709 - .into_response()) 710 - }