use crate::error::{ApiError, Result}; use crate::models::{EventV2, PendingEventV2, CreateEventRequestV2, SubmitEventRequestV2, ApiResponse, PaginatedResponse}; use crate::utils::{ response::success_response, pagination::PaginationHelper, datetime::{parse_datetime_with_timezone, DEFAULT_CHURCH_TIMEZONE}, validation::{ValidationBuilder, validate_recurring_type}, urls::UrlBuilder, common::ListQueryParams, converters::{convert_events_to_v2, convert_event_to_v2}, db_operations::EventOperations, }; use axum::{ extract::{Path, Query, State, Multipart}, Json, }; use uuid::Uuid; use chrono::{Datelike, Timelike}; use crate::{db, AppState}; // Use shared ListQueryParams instead of custom EventQuery // #[derive(Deserialize)] // pub struct EventQuery { // page: Option, // per_page: Option, // timezone: Option, // } pub async fn list( State(state): State, Query(query): Query, ) -> Result>>> { let timezone = query.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE); let pagination = PaginationHelper::from_query(query.page, query.per_page); let events = crate::db::events::list(&state.pool).await?; let total = events.len() as i64; // Apply pagination let start = pagination.offset as usize; let end = std::cmp::min(start + pagination.per_page as usize, events.len()); let paginated_events = if start < events.len() { events[start..end].to_vec() } else { Vec::new() }; // Convert to V2 format using shared converter let url_builder = UrlBuilder::new(); let events_v2 = convert_events_to_v2(paginated_events, timezone, &url_builder)?; let response = pagination.create_response(events_v2, total); Ok(success_response(response)) } pub async fn get_upcoming( State(state): State, Query(query): Query, ) -> Result>>> { let timezone = query.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE); let events = EventOperations::get_upcoming(&state.pool, 50).await?; let url_builder = UrlBuilder::new(); let events_v2 = convert_events_to_v2(events, timezone, &url_builder)?; Ok(success_response(events_v2)) } pub async fn get_featured( State(state): State, Query(query): Query, ) -> Result>>> { let timezone = query.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE); let events = EventOperations::get_featured(&state.pool, 10).await?; let url_builder = UrlBuilder::new(); let events_v2 = convert_events_to_v2(events, timezone, &url_builder)?; Ok(success_response(events_v2)) } pub async fn get_by_id( State(state): State, Path(id): Path, Query(query): Query, ) -> Result>> { let timezone = query.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE); let event = crate::db::events::get_by_id(&state.pool, &id).await? .ok_or_else(|| ApiError::NotFound("Event not found".to_string()))?; let url_builder = UrlBuilder::new(); let event_v2 = convert_event_to_v2(event, timezone, &url_builder)?; Ok(success_response(event_v2)) } pub async fn create( State(state): State, Json(req): Json, ) -> Result>> { let timezone = req.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE); ValidationBuilder::new() .require(&req.title, "title") .require(&req.description, "description") .require(&req.location, "location") .require(&req.category, "category") .validate_length(&req.title, "title", 1, 255) .validate_length(&req.description, "description", 1, 2000) .validate_url(&req.location_url.as_deref().unwrap_or(""), "location_url") .validate_timezone(timezone) .build()?; validate_recurring_type(&req.recurring_type)?; let start_time = parse_datetime_with_timezone(&req.start_time, Some(timezone))?; let end_time = parse_datetime_with_timezone(&req.end_time, Some(timezone))?; if end_time.utc <= start_time.utc { return Err(ApiError::ValidationError("End time must be after start time".to_string())); } let event_id = Uuid::new_v4(); let event = db::events::create(&state.pool, &event_id, &crate::models::CreateEventRequest { title: req.title, description: req.description, start_time: start_time.utc, end_time: end_time.utc, location: req.location, location_url: req.location_url, category: req.category, is_featured: req.is_featured, recurring_type: req.recurring_type, }).await?; let url_builder = UrlBuilder::new(); let event_v2 = convert_event_to_v2(event, timezone, &url_builder)?; Ok(success_response(event_v2)) } pub async fn submit( State(state): State, mut multipart: Multipart, ) -> Result>> { let mut req_data = SubmitEventRequestV2 { title: String::new(), description: String::new(), start_time: String::new(), end_time: String::new(), location: String::new(), location_url: None, category: String::new(), is_featured: None, recurring_type: None, bulletin_week: String::new(), submitter_email: None, timezone: None, }; let mut image_data: Option> = None; while let Some(field) = multipart.next_field().await .map_err(|e| ApiError::ValidationError(format!("Failed to read multipart field: {}", e)))? { let name = field.name() .ok_or_else(|| ApiError::ValidationError("Field name is required".to_string()))?; match name { "title" => req_data.title = field.text().await .map_err(|e| ApiError::ValidationError(format!("Failed to read title: {}", e)))?, "description" => req_data.description = field.text().await .map_err(|e| ApiError::ValidationError(format!("Failed to read description: {}", e)))?, "start_time" => req_data.start_time = field.text().await .map_err(|e| ApiError::ValidationError(format!("Failed to read start_time: {}", e)))?, "end_time" => req_data.end_time = field.text().await .map_err(|e| ApiError::ValidationError(format!("Failed to read end_time: {}", e)))?, "location" => req_data.location = field.text().await .map_err(|e| ApiError::ValidationError(format!("Failed to read location: {}", e)))?, "location_url" => { let url = field.text().await .map_err(|e| ApiError::ValidationError(format!("Failed to read location_url: {}", e)))?; req_data.location_url = if url.is_empty() { None } else { Some(url) }; }, "category" => req_data.category = field.text().await .map_err(|e| ApiError::ValidationError(format!("Failed to read category: {}", e)))?, "bulletin_week" => req_data.bulletin_week = field.text().await .map_err(|e| ApiError::ValidationError(format!("Failed to read bulletin_week: {}", e)))?, "submitter_email" => { let email = field.text().await .map_err(|e| ApiError::ValidationError(format!("Failed to read submitter_email: {}", e)))?; req_data.submitter_email = if email.is_empty() { None } else { Some(email) }; }, "timezone" => { let tz = field.text().await .map_err(|e| ApiError::ValidationError(format!("Failed to read timezone: {}", e)))?; req_data.timezone = if tz.is_empty() { None } else { Some(tz) }; }, "image" => { if field.file_name().is_some() { image_data = Some(field.bytes().await .map_err(|e| ApiError::ValidationError(format!("Failed to read image data: {}", e)))? .to_vec()); } }, _ => { // Skip unknown fields } } } let timezone = req_data.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE); // Auto-determine bulletin_week based on submission time // Before Friday 00:00 UTC (Thursday 7pm EST) = "current", after = "next" let now = chrono::Utc::now(); let current_weekday = now.weekday(); let current_hour = now.hour(); req_data.bulletin_week = match current_weekday { chrono::Weekday::Mon | chrono::Weekday::Tue | chrono::Weekday::Wed | chrono::Weekday::Thu => "current".to_string(), chrono::Weekday::Fri if current_hour == 0 => "current".to_string(), _ => "next".to_string(), }; ValidationBuilder::new() .require(&req_data.title, "title") .require(&req_data.description, "description") .require(&req_data.location, "location") .require(&req_data.category, "category") .validate_length(&req_data.title, "title", 1, 255) .validate_length(&req_data.description, "description", 1, 2000) .validate_url(&req_data.location_url.as_deref().unwrap_or(""), "location_url") .validate_timezone(timezone) .build()?; if let Some(email) = &req_data.submitter_email { ValidationBuilder::new().validate_email(email).build()?; } validate_recurring_type(&req_data.recurring_type)?; let start_time = parse_datetime_with_timezone(&req_data.start_time, Some(timezone))?; let end_time = parse_datetime_with_timezone(&req_data.end_time, Some(timezone))?; if end_time.utc <= start_time.utc { return Err(ApiError::ValidationError("End time must be after start time".to_string())); } let event_id = Uuid::new_v4(); let submit_request = crate::models::SubmitEventRequest { title: req_data.title, description: req_data.description, start_time: start_time.utc, end_time: end_time.utc, location: req_data.location, location_url: req_data.location_url, category: req_data.category, is_featured: req_data.is_featured, recurring_type: req_data.recurring_type, bulletin_week: req_data.bulletin_week, submitter_email: req_data.submitter_email, image: None, thumbnail: None, }; let _pending_event = db::events::submit(&state.pool, &event_id, &submit_request).await?; if let Some(image_bytes) = image_data { let image_path = format!("uploads/pending_events/{}_image.webp", event_id); let state_clone = state.clone(); let event_id_clone = event_id; crate::utils::tasks::spawn_with_error_handling("process_event_image", async move { let converted_image = crate::utils::images::convert_to_webp(&image_bytes)?; tokio::fs::write(&image_path, converted_image).await .map_err(|e| ApiError::Internal(format!("Failed to save image: {}", e)))?; db::events::update_pending_image(&state_clone.pool, &event_id_clone, &image_path).await?; Ok(()) }); } Ok(success_response("Event submitted successfully and is pending approval".to_string())) } // Converter functions moved to shared utils/converters.rs module pub async fn list_pending( State(state): State, Query(query): Query, ) -> Result>>> { let pagination = PaginationHelper::from_query(query.page, query.per_page); let timezone = query.timezone.as_deref().unwrap_or(DEFAULT_CHURCH_TIMEZONE); let events = db::events::list_pending(&state.pool, pagination.page, pagination.per_page).await?; let total = db::events::count_pending(&state.pool).await?; let mut events_v2 = Vec::new(); let url_builder = UrlBuilder::new(); for event in events { let event_v2 = crate::utils::converters::convert_pending_event_to_v2(event, timezone, &url_builder)?; events_v2.push(event_v2); } let response = pagination.create_response(events_v2, total); Ok(success_response(response)) }