// Middleware for extracting filter parameters from HTTP requests // // Parses query parameters and form data to construct EventFilterCriteria, // handling validation and normalization of input data. use axum::{ extract::{Request}, middleware::Next, response::Response, }; use chrono::{DateTime, Datelike, Utc}; use serde::Serialize; use std::collections::HashMap; use tracing::{debug, info, instrument, warn}; use crate::filtering::{EventFilterCriteria, EventSortField, LocationFilter, SortOrder}; use crate::atproto::lexicon::community::lexicon::calendar::event::{Mode, Status}; use crate::http::middleware_timezone::DetectedTimezone; /// Query parameters for event filtering #[derive(Debug, Clone, Serialize)] pub struct FilterQueryParams { /// Text search term pub q: Option, /// Start date in ISO format pub start_date: Option, /// End date in ISO format pub end_date: Option, /// Creator DID pub creator: Option, /// Event modes (multiple params or comma-separated) pub modes: Vec, /// Event statuses (multiple params or comma-separated) pub statuses: Vec, /// Date ranges (predefined ranges like "this-month", "today", etc.) pub date_ranges: Vec, /// Location latitude pub lat: Option, /// Location longitude pub lng: Option, /// Location radius in kilometers pub radius: Option, /// Location field (alternative to lat/lng) pub location: Option, /// Sort field pub sort: Option, /// Sort order (asc/desc) pub order: Option, /// Page number (1-based, as received from URL parameters) pub page: Option, /// Page size (either 'size' or 'per_page') pub size: Option, pub per_page: Option, } impl Default for FilterQueryParams { fn default() -> Self { Self { q: None, start_date: None, end_date: None, creator: None, modes: Vec::new(), statuses: Vec::new(), date_ranges: Vec::new(), lat: None, lng: None, radius: None, location: None, sort: None, order: None, page: None, size: None, per_page: None, } } } /// Extension for storing parsed filter criteria in request #[derive(Debug, Clone)] pub struct FilterCriteriaExtension { pub criteria: EventFilterCriteria, pub raw_params: FilterQueryParams, } /// Middleware function to extract and parse filter parameters #[instrument(skip(request, next))] pub async fn extract_filter_params( mut request: Request, next: Next, ) -> Result { // Extract query string from URI let query_string = request.uri().query().unwrap_or(""); info!("MIDDLEWARE: Processing request with query string: '{}'", query_string); // Parse query parameters using custom parser that handles array fields properly let query_params = if query_string.is_empty() { FilterQueryParams::default() } else { match parse_filter_query_params(query_string) { Ok(params) => params, Err(e) => { warn!("Failed to parse query parameters: {}", e); FilterQueryParams::default() } } }; // Extract timezone from request extensions (injected by timezone middleware) let detected_timezone = request.extensions().get::().cloned(); // Convert to EventFilterCriteria let criteria = match convert_to_criteria(&query_params, detected_timezone.as_ref()) { Ok(criteria) => criteria, Err(e) => { warn!("Failed to convert query parameters to criteria: {}", e); EventFilterCriteria::new() } }; // Store in request extensions request.extensions_mut().insert(FilterCriteriaExtension { criteria, raw_params: query_params, }); Ok(next.run(request).await) } /// Custom parser for URL query parameters that properly handles array fields fn parse_filter_query_params(query_string: &str) -> Result { info!("PARSING QUERY STRING: {}", query_string); let mut params = HashMap::new(); // Parse all parameters into a map of key -> Vec for param in query_string.split('&') { if let Some((key, value)) = param.split_once('=') { let decoded_key = urlencoding::decode(key).map_err(|e| format!("Invalid key encoding: {}", e))?; let decoded_value = urlencoding::decode(value).map_err(|e| format!("Invalid value encoding: {}", e))?; params.entry(decoded_key.to_string()) .or_insert_with(Vec::new) .push(decoded_value.to_string()); } } // Helper function to get the first value for single-value fields let get_single = |key: &str| -> Option { params.get(key).and_then(|v| v.first().cloned()).filter(|s| !s.is_empty()) }; // Helper function to parse numeric values let parse_numeric = |key: &str| -> Option { get_single(key).and_then(|v| v.parse().ok()) }; let parse_float = |key: &str| -> Option { get_single(key).and_then(|v| v.parse().ok()) }; // Helper function to get all values for array fields // This handles both multiple parameters with the same key AND comma-separated values within a single parameter let get_array = |key: &str| -> Vec { params.get(key) .map(|values| { values.iter() .flat_map(|v| v.split(',')) .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) .collect() }) .unwrap_or_default() }; let result = FilterQueryParams { q: get_single("q"), start_date: get_single("start_date"), end_date: get_single("end_date"), creator: get_single("creator"), modes: get_array("modes"), statuses: get_array("statuses"), date_ranges: get_array("date_ranges"), lat: parse_float("lat"), lng: parse_float("lng"), radius: parse_float("radius"), location: get_single("location"), sort: get_single("sort"), order: get_single("order"), page: parse_numeric("page"), size: parse_numeric("size"), per_page: parse_numeric("per_page"), }; info!("PARSED FilterQueryParams: {:?}", result); Ok(result) } /// Convert query parameters to EventFilterCriteria fn convert_to_criteria(params: &FilterQueryParams, detected_timezone: Option<&DetectedTimezone>) -> Result { let mut criteria = EventFilterCriteria::new(); // Search term if let Some(ref q) = params.q { if !q.trim().is_empty() { criteria.search_term = Some(q.trim().to_string()); } } // Date range if let Some(ref start_str) = params.start_date { criteria.start_date = parse_datetime(start_str) .map_err(|e| format!("Invalid start date: {}", e))?; } if let Some(ref end_str) = params.end_date { criteria.end_date = parse_datetime(end_str) .map_err(|e| format!("Invalid end date: {}", e))?; } // Handle date_ranges parameter (overrides start_date/end_date if present) if !params.date_ranges.is_empty() { info!("PROCESSING date_ranges: {:?}", params.date_ranges); let (start_date, end_date) = parse_date_ranges(¶ms.date_ranges)?; info!("PARSED date range: start={:?}, end={:?}", start_date, end_date); if let Some(start) = start_date { criteria.start_date = Some(start); } if let Some(end) = end_date { criteria.end_date = Some(end); } } // Set timezone-aware default date range if no dates specified // Default: today to +5 days in user's timezone if criteria.start_date.is_none() && criteria.end_date.is_none() && params.date_ranges.is_empty() { let timezone_str = detected_timezone .map(|tz| tz.timezone.as_str()) .unwrap_or("UTC"); info!("No date parameters provided, setting timezone-aware defaults for timezone: {}", timezone_str); // Parse timezone match timezone_str.parse::() { Ok(tz) => { let now_in_tz = Utc::now().with_timezone(&tz); let today = now_in_tz.date_naive(); let end_date = today + chrono::Duration::days(5); // Convert to UTC DateTime for storage let start_datetime_utc = today.and_hms_opt(0, 0, 0).unwrap().and_local_timezone(tz).unwrap().with_timezone(&Utc); let end_datetime_utc = end_date.and_hms_opt(23, 59, 59).unwrap().and_local_timezone(tz).unwrap().with_timezone(&Utc); criteria.start_date = Some(start_datetime_utc); criteria.end_date = Some(end_datetime_utc); info!("Set default date range: {} to {} (user timezone: {})", start_datetime_utc.format("%Y-%m-%d %H:%M:%S UTC"), end_datetime_utc.format("%Y-%m-%d %H:%M:%S UTC"), timezone_str); }, Err(_) => { // Fallback to UTC if timezone parsing fails warn!("Failed to parse timezone '{}', using UTC for default dates", timezone_str); let now_utc = Utc::now(); let today = now_utc.date_naive(); let end_date = today + chrono::Duration::days(5); criteria.start_date = Some(today.and_hms_opt(0, 0, 0).unwrap().and_utc()); criteria.end_date = Some(end_date.and_hms_opt(23, 59, 59).unwrap().and_utc()); info!("Set default date range (UTC fallback): {} to {}", criteria.start_date.unwrap().format("%Y-%m-%d %H:%M:%S UTC"), criteria.end_date.unwrap().format("%Y-%m-%d %H:%M:%S UTC")); } } } // Creator if let Some(ref creator) = params.creator { if !creator.trim().is_empty() { criteria.creator_did = Some(creator.trim().to_string()); } } // Modes if !params.modes.is_empty() { criteria.modes = parse_modes_from_vec(¶ms.modes); } // Statuses if !params.statuses.is_empty() { criteria.statuses = parse_statuses_from_vec(¶ms.statuses); } // Location if let (Some(lat), Some(lng)) = (params.lat, params.lng) { let radius = params.radius.unwrap_or(10.0); // Default 10km radius if lat >= -90.0 && lat <= 90.0 && lng >= -180.0 && lng <= 180.0 && radius > 0.0 { criteria.location = Some(LocationFilter { latitude: lat, longitude: lng, radius_km: radius, }); } else { return Err("Invalid location parameters".to_string()); } } // Sorting if let Some(ref sort_str) = params.sort { criteria.sort_by = parse_sort_field(sort_str)?; // Extract sort order from combined parameters (e.g., "date_asc" -> Ascending) let sort_lower = sort_str.to_lowercase(); if sort_lower.ends_with("_asc") { criteria.sort_order = SortOrder::Ascending; } else if sort_lower.ends_with("_desc") { criteria.sort_order = SortOrder::Descending; } } // Allow explicit order parameter to override combined parameter if let Some(ref order_str) = params.order { criteria.sort_order = parse_sort_order(order_str)?; } // Pagination if let Some(page) = params.page { criteria.page = page; } // Handle both 'size' and 'per_page' parameters let page_size = params.per_page.or(params.size); if let Some(size) = page_size { if size > 0 && size <= 100 { criteria.page_size = size; } else { return Err("Page size must be between 1 and 100".to_string()); } } Ok(criteria) } /// Parse datetime string (ISO 8601 format) fn parse_datetime(date_str: &str) -> Result>, String> { if date_str.trim().is_empty() { return Ok(None); } // Try different datetime formats let formats = [ "%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%dT%H:%M:%S%.fZ", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d", ]; for format in &formats { if let Ok(naive_dt) = chrono::NaiveDateTime::parse_from_str(date_str, format) { return Ok(Some(naive_dt.and_utc())); } if let Ok(date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") { return Ok(Some(date.and_hms_opt(0, 0, 0).unwrap().and_utc())); } } // Try parsing as RFC 3339 match DateTime::parse_from_rfc3339(date_str) { Ok(dt) => Ok(Some(dt.with_timezone(&Utc))), Err(_) => Err(format!("Unable to parse date: {}", date_str)), } } /// Parse modes from comma-separated string fn parse_modes(modes_str: &str) -> Vec { modes_str .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) .filter_map(|s| { // Handle both full schema identifiers and simple strings let mode_part = if s.contains('#') { // Extract the part after the # for schema identifiers like "community.lexicon.calendar.event#hybrid" s.split('#').nth(1).unwrap_or(s) } else { s }; match mode_part.to_lowercase().as_str() { "inperson" | "in-person" | "in_person" => Some(Mode::InPerson), "virtual" => Some(Mode::Virtual), "hybrid" => Some(Mode::Hybrid), _ => None, } }) .collect() } /// Parse modes from vector of strings (handles both individual values and comma-separated) fn parse_modes_from_vec(modes_vec: &[String]) -> Vec { let mut result = Vec::new(); for mode_str in modes_vec { result.extend(parse_modes(mode_str)); } result } /// Parse statuses from comma-separated string fn parse_statuses(statuses_str: &str) -> Vec { statuses_str .split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) .filter_map(|s| { // Handle both full schema identifiers and simple strings let status_part = if s.contains('#') { // Extract the part after the # for schema identifiers like "community.lexicon.calendar.event#cancelled" s.split('#').nth(1).unwrap_or(s) } else { s }; match status_part.to_lowercase().as_str() { "scheduled" => Some(Status::Scheduled), "rescheduled" => Some(Status::Rescheduled), "cancelled" | "canceled" => Some(Status::Cancelled), "postponed" => Some(Status::Postponed), "planned" => Some(Status::Planned), _ => None, } }) .collect() } /// Parse statuses from vector of strings (handles both individual values and comma-separated) fn parse_statuses_from_vec(statuses_vec: &[String]) -> Vec { let mut result = Vec::new(); for status_str in statuses_vec { result.extend(parse_statuses(status_str)); } result } /// Parse sort field from string fn parse_sort_field(sort_str: &str) -> Result { let sort_lower = sort_str.to_lowercase(); // Handle combined field_order format (e.g., "date_asc", "name_desc") let field_part = if sort_lower.ends_with("_asc") || sort_lower.ends_with("_desc") { let underscore_pos = sort_lower.rfind('_').unwrap(); &sort_lower[..underscore_pos] } else { &sort_lower }; match field_part { "start_time" | "starts_at" | "date" => Ok(EventSortField::StartTime), "created_at" | "created" | "updated_at" | "updated" => Ok(EventSortField::UpdatedAt), "name" | "title" => Ok(EventSortField::Name), "popularity" | "rsvp_count" => Ok(EventSortField::PopularityRsvp), _ => Err(format!("Unknown sort field: {}", sort_str)), } } /// Parse sort order from string fn parse_sort_order(order_str: &str) -> Result { match order_str.to_lowercase().as_str() { "asc" | "ascending" => Ok(SortOrder::Ascending), "desc" | "descending" => Ok(SortOrder::Descending), _ => Err(format!("Unknown sort order: {}", order_str)), } } /// Parse date ranges from vector of strings and return combined start/end dates fn parse_date_ranges(date_ranges: &[String]) -> Result<(Option>, Option>), String> { if date_ranges.is_empty() { return Ok((None, None)); } let now = chrono::Utc::now(); let mut earliest_start: Option> = None; let mut latest_end: Option> = None; for range_key in date_ranges { let range_key = range_key.trim(); if range_key.is_empty() { continue; } let (start_date, end_date) = match range_key { "today" => { let start = now.date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); let end = now.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc(); (start, end) } "this_week" | "this-week" => { let days_since_monday = now.weekday().num_days_from_monday(); let start = (now - chrono::Duration::days(days_since_monday as i64)) .date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); let end = start + chrono::Duration::days(6); (start, end) } "this_month" | "this-month" => { let start = now.date_naive().with_day(1).unwrap().and_hms_opt(0, 0, 0).unwrap().and_utc(); let end = if now.month() == 12 { chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1).unwrap() } else { chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1).unwrap() }.and_hms_opt(0, 0, 0).unwrap().and_utc(); (start, end) } "next_week" | "next-week" => { // Next week starts on the Monday after this week ends let days_since_monday = now.weekday().num_days_from_monday(); let days_until_next_monday = 7 - days_since_monday; let start = (now + chrono::Duration::days(days_until_next_monday as i64)) .date_naive().and_hms_opt(0, 0, 0).unwrap().and_utc(); let end = start + chrono::Duration::days(6); let end = end.date_naive().and_hms_opt(23, 59, 59).unwrap().and_utc(); debug!("Next week calculation: now={}, days_since_monday={}, days_until_next_monday={}, start={}, end={}", now, days_since_monday, days_until_next_monday, start, end); (start, end) } "next_month" | "next-month" => { let start = if now.month() == 12 { chrono::NaiveDate::from_ymd_opt(now.year() + 1, 1, 1).unwrap() } else { chrono::NaiveDate::from_ymd_opt(now.year(), now.month() + 1, 1).unwrap() }.and_hms_opt(0, 0, 0).unwrap().and_utc(); let end = if start.month() == 12 { chrono::NaiveDate::from_ymd_opt(start.year() + 1, 1, 1).unwrap() } else { chrono::NaiveDate::from_ymd_opt(start.year(), start.month() + 1, 1).unwrap() }.and_hms_opt(0, 0, 0).unwrap().and_utc(); (start, end) } _ => return Err(format!("Unknown date range: {}", range_key)), }; // Update the overall date range if earliest_start.is_none() || start_date < earliest_start.unwrap() { earliest_start = Some(start_date); } if latest_end.is_none() || end_date > latest_end.unwrap() { latest_end = Some(end_date); } } Ok((earliest_start, latest_end)) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_datetime() { assert!(parse_datetime("2024-01-01").unwrap().is_some()); assert!(parse_datetime("2024-01-01T10:00:00Z").unwrap().is_some()); assert!(parse_datetime("").unwrap().is_none()); assert!(parse_datetime("invalid").is_err()); } #[test] fn test_parse_modes() { // Test simple mode strings let modes = parse_modes("inperson,virtual,hybrid"); assert_eq!(modes, vec![Mode::InPerson, Mode::Virtual, Mode::Hybrid]); let with_variations = parse_modes("in-person, virtual, hybrid"); assert_eq!(with_variations, vec![Mode::InPerson, Mode::Virtual, Mode::Hybrid]); // Test full schema identifiers (as sent by the frontend) let schema_modes = parse_modes("community.lexicon.calendar.event#hybrid,community.lexicon.calendar.event#virtual,community.lexicon.calendar.event#inperson"); assert_eq!(schema_modes, vec![Mode::Hybrid, Mode::Virtual, Mode::InPerson]); // Test mixed formats let mixed = parse_modes("hybrid,community.lexicon.calendar.event#virtual"); assert_eq!(mixed, vec![Mode::Hybrid, Mode::Virtual]); let empty = parse_modes(""); assert!(empty.is_empty()); } #[test] fn test_parse_statuses() { let statuses = parse_statuses("scheduled,cancelled,planned"); assert_eq!(statuses, vec![Status::Scheduled, Status::Cancelled, Status::Planned]); let with_variations = parse_statuses("canceled, postponed"); assert_eq!(with_variations, vec![Status::Cancelled, Status::Postponed]); // Test full schema identifiers (as sent by the frontend) let schema_statuses = parse_statuses("community.lexicon.calendar.event#scheduled,community.lexicon.calendar.event#cancelled,community.lexicon.calendar.event#planned"); assert_eq!(schema_statuses, vec![Status::Scheduled, Status::Cancelled, Status::Planned]); // Test mixed formats let mixed = parse_statuses("cancelled,community.lexicon.calendar.event#scheduled"); assert_eq!(mixed, vec![Status::Cancelled, Status::Scheduled]); let empty = parse_statuses(""); assert!(empty.is_empty()); } #[test] fn test_parse_sort_field() { // Test basic field names assert!(matches!(parse_sort_field("name"), Ok(EventSortField::Name))); assert!(matches!(parse_sort_field("date"), Ok(EventSortField::StartTime))); // Test combined field_order parameters assert!(matches!(parse_sort_field("date_asc"), Ok(EventSortField::StartTime))); assert!(matches!(parse_sort_field("date_desc"), Ok(EventSortField::StartTime))); assert!(matches!(parse_sort_field("name_asc"), Ok(EventSortField::Name))); assert!(matches!(parse_sort_field("popularity_desc"), Ok(EventSortField::PopularityRsvp))); // Test case insensitivity assert!(matches!(parse_sort_field("DATE_ASC"), Ok(EventSortField::StartTime))); assert!(parse_sort_field("invalid").is_err()); assert!(parse_sort_field("invalid_asc").is_err()); } #[test] fn test_parse_date_ranges() { // Test single date range let single = parse_date_ranges(&["this-month".to_string()]).unwrap(); assert!(single.0.is_some()); // start_date should be set assert!(single.1.is_some()); // end_date should be set // Test empty ranges let empty = parse_date_ranges(&[]).unwrap(); assert!(empty.0.is_none()); assert!(empty.1.is_none()); // Test invalid range let invalid = parse_date_ranges(&["invalid-range".to_string()]); assert!(invalid.is_err()); // Test multiple date ranges let multiple = parse_date_ranges(&["today".to_string(), "this-week".to_string()]).unwrap(); assert!(multiple.0.is_some()); assert!(multiple.1.is_some()); } #[test] fn test_convert_to_criteria() { let params = FilterQueryParams { q: Some("conference".to_string()), page: Some(1), size: Some(20), ..Default::default() }; let criteria = convert_to_criteria(¶ms, None).unwrap(); assert_eq!(criteria.search_term, Some("conference".to_string())); assert_eq!(criteria.page, 1); assert_eq!(criteria.page_size, 20); } #[test] fn test_parse_filter_query_params() { let query = "q=event&modes=virtual,inperson&page=2&size=10"; let params = parse_filter_query_params(query).unwrap(); assert_eq!(params.q, Some("event".to_string())); assert_eq!(params.modes, vec!["virtual".to_string(), "inperson".to_string()]); assert_eq!(params.page, Some(2)); assert_eq!(params.size, Some(10)); } }