
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.
304 lines
12 KiB
Rust
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))
|
|
} |