From 8728e141250f57093642986431fb8c5216e6c01b Mon Sep 17 00:00:00 2001 From: Benjamin Slingo Date: Sun, 7 Sep 2025 21:55:49 -0400 Subject: [PATCH] Optimize N+1 queries and fix missing event update endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance optimizations: - Fix media items N+1: batch scripture reading lookups (1+N → 2 queries) - Fix hymnal thematic lists N+1: batch ambits queries (1+N → 2 queries) - Add batch query functions for bulletins and hymnal ambits Bug fix: - Add missing PUT /api/admin/events/:id endpoint for event updates - Connect existing EventsV1Service::update method to web layer Database performance significantly improved for high-traffic endpoints used by website, iOS app, and LuminaLP integrations. --- src/handlers/events.rs | 13 ++++++++++++- src/handlers/media.rs | 25 ++++++++++++++++++++----- src/main.rs | 1 + src/services/hymnal.rs | 23 ++++++++++++++--------- src/sql/bulletins.rs | 31 +++++++++++++++++++++++++++++++ src/sql/hymnal.rs | 35 +++++++++++++++++++++++++++++++++++ 6 files changed, 113 insertions(+), 15 deletions(-) diff --git a/src/handlers/events.rs b/src/handlers/events.rs index 653363b..ac49344 100644 --- a/src/handlers/events.rs +++ b/src/handlers/events.rs @@ -22,7 +22,7 @@ use tokio::fs; use crate::{ services::{EventsV1Service, PendingEventsService}, error::Result, - models::{Event, PendingEvent, ApiResponse, PaginatedResponse}, + models::{Event, PendingEvent, ApiResponse, PaginatedResponse, UpdateEventRequest}, AppState, }; @@ -218,3 +218,14 @@ pub async fn delete_pending( PendingEventsService::delete(&state.pool, &id).await?; Ok(success_with_message("Pending event deleted successfully".to_string(), "Pending event deleted successfully")) } + +/// Update an existing event (admin only) +pub async fn update( + State(state): State, + Path(id): Path, + Json(request): Json, +) -> Result>> { + let updated_event = EventsV1Service::update(&state.pool, &id, request).await?; + + Ok(success_with_message(updated_event, "Event updated successfully")) +} diff --git a/src/handlers/media.rs b/src/handlers/media.rs index 31fbdad..737cd11 100644 --- a/src/handlers/media.rs +++ b/src/handlers/media.rs @@ -124,11 +124,26 @@ pub async fn list_sermons( .await .map_err(|e| crate::error::ApiError::Database(e.to_string()))?; - // Link sermons to bulletins for scripture readings using shared SQL - for item in &mut media_items { - if item.scripture_reading.is_none() && item.date.is_some() { - if let Ok(Some(bulletin_data)) = sql::bulletins::get_by_date_for_scripture(&state.pool, item.date.unwrap()).await { - item.scripture_reading = bulletin_data.scripture_reading; + // Link sermons to bulletins for scripture readings using optimized batch query + let dates_needing_scripture: Vec = media_items + .iter() + .filter_map(|item| { + if item.scripture_reading.is_none() && item.date.is_some() { + Some(item.date.unwrap()) + } else { + None + } + }) + .collect(); + + if !dates_needing_scripture.is_empty() { + if let Ok(scripture_map) = sql::bulletins::get_scripture_readings_batch(&state.pool, &dates_needing_scripture).await { + for item in &mut media_items { + if item.scripture_reading.is_none() && item.date.is_some() { + if let Some(scripture) = scripture_map.get(&item.date.unwrap()) { + item.scripture_reading = scripture.clone(); + } + } } } } diff --git a/src/main.rs b/src/main.rs index b436d41..6b4da47 100644 --- a/src/main.rs +++ b/src/main.rs @@ -86,6 +86,7 @@ async fn main() -> Result<()> { .route("/events/pending/:id/approve", post(handlers::events::approve)) .route("/events/pending/:id/reject", post(handlers::events::reject)) .route("/events/pending/:id", delete(handlers::events::delete_pending)) + .route("/events/:id", put(handlers::events::update)) .route("/events/:id", delete(handlers::events::delete)) .route("/config", get(handlers::config::get_admin_config)) .route("/config", put(handlers::config::update_config)) diff --git a/src/services/hymnal.rs b/src/services/hymnal.rs index 74b956a..f57ff95 100644 --- a/src/services/hymnal.rs +++ b/src/services/hymnal.rs @@ -155,21 +155,26 @@ impl HymnalService { // Thematic list operations pub async fn list_thematic_lists(pool: &PgPool, hymnal_id: Uuid) -> Result> { let lists = hymnal::get_thematic_lists(pool, &hymnal_id).await?; - let mut result = Vec::new(); - - for list in lists { - let ambits = hymnal::get_thematic_ambits(pool, &list.id).await?; - - result.push(ThematicListWithAmbits { + + // Extract all list IDs for batch query + let list_ids: Vec = lists.iter().map(|list| list.id).collect(); + + // Get all ambits in single query + let ambits_map = hymnal::get_thematic_ambits_batch(pool, &list_ids).await?; + + // Build results using the batch data + let result = lists + .into_iter() + .map(|list| ThematicListWithAmbits { id: list.id, hymnal_id: list.hymnal_id, name: list.name, sort_order: list.sort_order, - ambits, + ambits: ambits_map.get(&list.id).cloned().unwrap_or_default(), created_at: list.created_at, updated_at: list.updated_at, - }); - } + }) + .collect(); Ok(result) } diff --git a/src/sql/bulletins.rs b/src/sql/bulletins.rs index 4ce187e..b864634 100644 --- a/src/sql/bulletins.rs +++ b/src/sql/bulletins.rs @@ -67,6 +67,37 @@ pub async fn get_by_date_for_scripture(pool: &PgPool, date: chrono::NaiveDate) - Ok(bulletin) } +/// Get scripture readings for multiple dates in single query (optimization for media items) +pub async fn get_scripture_readings_batch(pool: &PgPool, dates: &[chrono::NaiveDate]) -> Result>> { + if dates.is_empty() { + return Ok(std::collections::HashMap::new()); + } + + let results = sqlx::query!( + r#"SELECT DISTINCT ON (date) date, scripture_reading + FROM bulletins + WHERE date = ANY($1) AND is_active = true + ORDER BY date, created_at DESC"#, + dates + ) + .fetch_all(pool) + .await?; + + let mut scripture_map = std::collections::HashMap::new(); + + // Initialize all dates with None + for date in dates { + scripture_map.insert(*date, None); + } + + // Fill in the scripture readings we found + for row in results { + scripture_map.insert(row.date, row.scripture_reading); + } + + Ok(scripture_map) +} + /// Get current bulletin (raw SQL, no conversion) pub async fn get_current(pool: &PgPool) -> Result> { sqlx::query_as!( diff --git a/src/sql/hymnal.rs b/src/sql/hymnal.rs index 36cfd16..01de6e3 100644 --- a/src/sql/hymnal.rs +++ b/src/sql/hymnal.rs @@ -445,6 +445,41 @@ pub async fn get_thematic_ambits(pool: &PgPool, list_id: &Uuid) -> Result Result>> { + if list_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + + let ambits = sqlx::query_as!( + crate::models::ThematicAmbit, + r#" + SELECT id, thematic_list_id, name, start_number, end_number, sort_order, created_at, updated_at + FROM thematic_ambits + WHERE thematic_list_id = ANY($1) + ORDER BY thematic_list_id, sort_order, name + "#, + list_ids + ) + .fetch_all(pool) + .await + .map_err(|e| crate::error::ApiError::DatabaseError(e))?; + + let mut ambits_map: std::collections::HashMap> = std::collections::HashMap::new(); + + // Initialize all list_ids with empty vectors + for list_id in list_ids { + ambits_map.insert(*list_id, Vec::new()); + } + + // Group ambits by their list_id + for ambit in ambits { + ambits_map.entry(ambit.thematic_list_id).or_insert_with(Vec::new).push(ambit); + } + + Ok(ambits_map) +} + /// Count responsive readings pub async fn count_responsive_readings(pool: &PgPool) -> Result { let count = sqlx::query!("SELECT COUNT(*) as count FROM responsive_readings")