church-api/src/handlers/v2/events.rs
Benjamin Slingo 0c06e159bb Initial commit: Church API Rust implementation
Complete church management system with bulletin management, media processing, live streaming integration, and web interface. Includes authentication, email notifications, database migrations, and comprehensive test suite.
2025-08-19 20:56:41 -04:00

304 lines
12 KiB
Rust

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<i32>,
// per_page: Option<i32>,
// timezone: Option<String>,
// }
pub async fn list(
State(state): State<AppState>,
Query(query): Query<ListQueryParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<EventV2>>>> {
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<AppState>,
Query(query): Query<ListQueryParams>,
) -> Result<Json<ApiResponse<Vec<EventV2>>>> {
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<AppState>,
Query(query): Query<ListQueryParams>,
) -> Result<Json<ApiResponse<Vec<EventV2>>>> {
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<AppState>,
Path(id): Path<Uuid>,
Query(query): Query<ListQueryParams>,
) -> Result<Json<ApiResponse<EventV2>>> {
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<AppState>,
Json(req): Json<CreateEventRequestV2>,
) -> Result<Json<ApiResponse<EventV2>>> {
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<AppState>,
mut multipart: Multipart,
) -> Result<Json<ApiResponse<String>>> {
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<Vec<u8>> = 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<AppState>,
Query(query): Query<ListQueryParams>,
) -> Result<Json<ApiResponse<PaginatedResponse<PendingEventV2>>>> {
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))
}